Compare commits

..

18 Commits

Author SHA1 Message Date
Nathaniel Parson Koroso
afe5568d07 test: cover widget value leak on node id reuse after clear
Fails on the old guard (a re-added node with a reused id reads the deleted node's width) and passes with the zero-UUID purge.
2026-07-14 20:02:27 -07:00
Nathaniel Parson Koroso
a5dfaf4462 fix(litegraph): purge widget state when a zero-UUID graph is cleared
LGraph.clear() only purged widgetValueStore/previewExposureStore when the graph id was non-zero, so a root graph running at the zero UUID never purged. The API-format load path clears then re-adds nodes without assigning an id, and a freshly constructed graph also stays at the zero UUID, so a node reusing an id after clear inherited the previous node's same-named widget value. That stale value serializes into prompts and fails backend validation ('Value not in list').

Gate the zero-UUID case on the graph having nodes, so real teardowns purge while the constructor's clear() and throwaway graphs (e.g. insertWorkflow's clipboard graph) don't wipe the active graph's shared zero-UUID namespace.

Adds LGraph.test.ts coverage for zero-UUID node-id reuse and the throwaway-construction guard, plus a widgetValueStore.test.ts case for re-registering a reused widget id after clearGraph.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 18:32:11 -07:00
imick-io
010389903d feat(website): sitewide announcement banner (#13481)
## Summary

Adds a **sitewide announcement banner** to the website, rendered above
the navbar on every page. It has a two-layer visibility model:

- **Build-time gate** — a pure, unit-tested `evaluateBannerVisibility()`
decides whether the banner mounts at all (active flag + optional date
window + locale/section targeting), driven by a typed config
(`src/config/banner.ts`). No CMS; copy resolves through i18n.
- **Client-side dismissal** — persisted in `localStorage`, keyed by a
**content hash** so editing the copy re-shows the banner (per-locale, so
an en edit doesn't re-show it for zh-CN).

Current content points the CTA at **Comfy MCP** (`/mcp`).

## Highlights

- **Full-width branded bar** above a now-`sticky` navbar; reuses the
design system (`Button`, gradient tokens, new reusable `IconButton`).
- **Flash-free** on load: an inline pre-hydration script hides an
already-dismissed banner before paint (no pop-in, no layout shift);
`close()` sets the same signal after the leave animation to stay
flash-free across ClientRouter navigations.
- **Open/close transition**: grid-rows height collapse + fade,
respecting `prefers-reduced-motion`.
- **i18n**: copy in `en` + `zh-CN`.

## Where to edit later

- **Copy**: `apps/website/src/i18n/translations.ts` →
`launches.banner.text` / `launches.banner.cta`
- **Link / on-off / dates / targeting**:
`apps/website/src/config/banner.ts` (`bannerConfig`)

## Notes

- On a static site the `startsAt`/`endsAt` window is evaluated at
**build time** (documented in `banner.ts`).
- Changing the copy or link changes the content hash, so
previously-dismissed visitors will see the banner again — by design.

## Test plan

- `pnpm test:unit` — evaluator + version-hash unit tests pass.
- `pnpm typecheck` + lint clean.
- On the preview: banner shows on `/`, `/launches`, and a `zh-CN` page;
CTA -> `/mcp`; dismiss animates and stays dismissed on reload (no
flash); reduced-motion disables the animation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 01:56:48 +00:00
Benjamin Lu
684b0b08b0 feat(telemetry): register client + deployment platform axes on PostHog (#13469)
## Problem

Filtering PostHog for the three product surfaces — **desktop local**,
**desktop cloud**, **web cloud** — currently requires a different hack
per pipe. For this repo's cloud build, the desktop-embedded frontend and
a plain browser are indistinguishable except by sniffing `Electron` in
`$raw_user_agent` (~124K desktop-cloud vs ~844K web-cloud execution
events/week get separated that way today).

## Change

Register the two standardized platform axes as PostHog super properties
at SDK init in `PostHogTelemetryProvider`:

- **`client`** — which surface emitted the event: `'desktop'` when the
desktop preload bridge (`window.__comfyDesktop2`) is present, else
`'web'`. The bridge is injected by Electron before any page script runs,
so detection is deterministic — unlike the existing utm-based
`source_app` attribution, which only covers sessions that *entered* via
a desktop link.
- **`deployment`** — which backend runs the work: pinned to `'cloud'`.

The register happens before the pre-init event queue flushes, so events
captured during the posthog-js dynamic-import window carry the axes too.

## Why pinning `deployment: 'cloud'` is safe (including
embedded-in-desktop)

The cloud bundle also runs **embedded in Comfy Desktop** — a cloud
install loads this same bundle in Electron, where `isCloud` and the host
bridge are both true. Two things happen there:

1. `main.ts` runs `initHostTelemetry()` *after* `initTelemetry()`, and
(when remote config `enable_telemetry` is on) it **replaces** the
registry with `HostTelemetrySink` — so tracked events
(`execution_start`, …) route through the desktop main process, bypassing
this provider. Those are tagged the same `client`/`deployment` values
main-side from the install's source category
([Comfy-Desktop#1229](https://github.com/Comfy-Org/Comfy-Desktop/pull/1229)).
2. posthog-js keeps capturing independently of the registry (pageviews,
web vitals, identify) — those are what these super properties cover in
the embedded case, and `deployment: 'cloud'` is correct for them because
the cloud bundle always talks to the cloud backend regardless of
embedding; the embedding itself is what `client: 'desktop'` captures.

The only way a cloud build runs against a non-cloud backend is a dev
setup, where `window.__CONFIG__.posthog_project_token` is absent
(injected by the cloud server) and the provider disables itself before
registering anything.

The locally-served frontend (desktop/localhost builds) never runs this
provider: `__DISTRIBUTION__` is a compile-time define, so the
`initTelemetry()` call folds away, with a runtime `IS_CLOUD_BUILD` guard
as backstop.

With both PRs, the three platforms become clean property filters:

| Surface | Filter |
|---|---|
| Desktop local | `client=desktop, deployment=local` |
| Desktop cloud | `client=desktop, deployment=cloud` |
| Web cloud | `client=web, deployment=cloud` |

## Testing

- `vitest run` on `PostHogTelemetryProvider.test.ts` — 45 passing,
including new coverage: web default, bridge-present → `client=desktop`,
and register-before-queue-flush ordering. The two desktop-entry tests
that asserted `register` is never called were narrowed to assert no
`source_app` register call.
- `pnpm typecheck` + eslint/oxlint on touched files — clean.

Ref
[MAR-51](https://linear.app/comfyorg/issue/MAR-51/foundation-desktop-sdk-dual-send-to-posthog-alongside-mixpanel)

---------

Co-authored-by: AustinMroz <austin@comfy.org>
2026-07-08 01:11:01 +00:00
Benjamin Lu
95b121bed9 fix(website): unbreak website-e2e on main (card count, CTA overflow, stale goldens) (#13496)
## Summary

Fix the website-e2e job, red on main since 2026-07-07 15:19 UTC, by
updating the cloud model-card count test and regenerating stale visual
screenshot goldens.

## Context

website-e2e runs Playwright tests against the marketing site
(`apps/website`). It broke on main in two independent ways, so every PR
since — however unrelated — has shown a red website-e2e check:

1. **Model-card count.** #13431 added a sixth model card (GPT Image 2)
to the /cloud "AI models" section but didn't update the test that pins
the card count at 5. CI: `locator resolved to 6 elements`.
2. **Stale screenshot goldens.** #13431 also swapped the ProductCard CTA
to the shared `Button` (`whitespace-nowrap`, so e.g. "SEE ENTERPRISE
FEATURES" renders on one line instead of wrapping) and committed
matching `home-product-cards-*` goldens. Minutes later #13445 — a branch
cut from main *before* #13431 — ran the screenshot-regen workflow, which
checks out the raw PR branch, not the branch merged with main. Its
regenerated lg/xl goldens therefore depict the **old** pre-#13431 card
(wrapped label; pixel-identical layout to the pre-#13431 golden), and
overwrote #13431's correct ones at merge. #13445's own website-e2e was
red at merge time for exactly this reason. The sm/md goldens (last
captured by #13431, without #13445's `ppformula-text-center`
0.19em→0.1em nudge) went stale by ~900 px the moment #13445 landed.

## Changes

- **What**: `cloud.spec.ts` — model-card count assertion and test title
5 → 6 (verified against the 6 entries in `AIModelsSection.vue`; passes
locally).
- **What**: `ProductCard.vue` — `h-auto whitespace-normal` on the CTA
`Button`. The shared Button's `whitespace-nowrap` made long labels ("SEE
ENTERPRISE FEATURES") overflow past the card edge at lg/xl (live on prod
since #13431); labels now wrap inside the card as they did before
#13431.
- **What**: regenerated `home-product-cards-*` goldens via the `Update
Website Screenshots` workflow, run on this branch, so they capture the
fixed rendering rather than enshrining the overflow.

## Review Focus

- At lg, "SEE DESKTOP/CLOUD FEATURES" now also wrap to two lines: #13431
raised the CTA font from `text-xs` to `md:text-sm`, so those labels no
longer fit one line in the 200px content box either (pre-fix they
silently consumed the card padding). If design prefers one-liners,
shrinking the CTA font is a follow-up.
- Process gaps this incident exposed (follow-ups, not in this PR): the
regen workflow captures against the raw branch instead of the
main-merged result, and website-e2e was red on #13445 at merge without
blocking it.

---

*Six cards where five once stood,*
*a button's text sat where it should —*
*but pixels pinned in amber lied,*
*so Linux looked, and rectified.*

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-07-07 17:59:21 -07:00
Wei Hai
baeb6df662 Gate signup submit on Turnstile being enabled, not enforced (#13463)
## Summary
- Shadow-mode Turnstile never blocked the signup Submit button on the
async Cloudflare challenge resolving, so most real submits raced ahead
of the widget and reached the backend with an empty token. This defeated
the point of shadow mode, which needs real tokens to measure the
false-positive rate before flipping to enforce.
- Submit is now blocked while the widget is enabled (shadow or enforce)
and has no token yet, in both modes.
- To keep a broken or slow Cloudflare load (network issue, ad-blocker,
CDN outage) from permanently blocking a legitimate signup,
`TurnstileWidget` now reports itself "unavailable" on a script-load
failure, a challenge error, or a 9s load timeout, and the form treats
that the same as shadow previously did: proceed without a token.

## Test plan
- [x] `vitest run` on `TurnstileWidget.test.ts` + `SignUpForm.test.ts`
(unit tests updated/added, all passing)
- [x] `pnpm typecheck` / eslint / oxlint / stylelint / oxfmt via
pre-commit hooks
- [ ] Manual click-through on staging to confirm no perceptible UX
regression during normal-latency challenge solves
- [ ] Confirm the 9s fallback timeout against real p95 Turnstile
challenge-solve latency

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-08 00:39:58 +00:00
CodeJuggernaut
e58b231664 fix: suppress the stray focus ring on the sidebar splitter panel (#13482)
Clicking empty space in a workspace panel focuses the whole PrimeVue
SplitterPanel (tabindex=-1 makes it click-focusable), and any following
non-chord keypress (e.g. Shift) trips the browser focus-visible
heuristic, painting the default blue ring around the entire panel.

The wrappers are not Tab-reachable (Tab lands on the controls inside,
never the panel box) and nothing focuses them programmatically, so the
ring conveys nothing. `focus-visible:outline-hidden` suppresses it while
keeping a forced-colors (High Contrast) indicator. Confirmed as noise
with design (Alex Tov).

Covers every click-focusable panel in
`LiteGraphCanvasSplitterOverlay.vue`: the sidebar panel (both
locations), the properties-side panel (both branches), and the bottom
panel. The center and graph-canvas panels are left untouched - they
inherit `pointer-events-none`, so a click can never focus them.

## Repro / QA

Ring trigger: click an empty, non-interactive spot inside the panel,
then press solo Shift. On main the browser paints a blue ring around the
whole panel; on this PR nothing appears. (Ctrl+Shift only triggers when
Shift lands first, hence the original "sometimes".)

| Panel | How to open | Fixed |
| --- | --- | --- |
| Sidebar (left, default) | Any rail icon, e.g. Assets | yes |
| Sidebar (right) | Settings > Sidebar Location > right | yes |
| Properties-side panel | Toggle properties panel / builder mode | yes |
| Bottom panel | Toggle Logs/Terminal | yes |
| Canvas / center | n/a | untouched - pointer-events-none, cannot be
click-focused |

- [ ] Each fixed panel: click empty spot, press Shift, no ring
- [ ] Same steps on main/prod show the ring (before-state)
- [ ] Tab still reaches controls inside each panel and their own focus
rings still show
- [ ] Media Assets shortcuts unchanged (Ctrl/Cmd+A, marquee modifiers) -
PR is CSS-only

- Surfaced during design review of #13323
2026-07-08 00:33:41 +00:00
Comfy Org PR Bot
545b48ee5b [chore] Update Ingest API types from cloud@421de6d (#12777)
## Automated Ingest API Type Update

This PR updates the Ingest API TypeScript types and Zod schemas from the
latest cloud OpenAPI specification.

- Cloud commit: 421de6d
- Generated using @hey-api/openapi-ts with Zod plugin

These types cover cloud-only endpoints (workspaces, billing, secrets,
assets, tasks, etc.).
Overlapping endpoints shared with the local ComfyUI Python backend are
excluded.

---------

Co-authored-by: mattmillerai <7741082+mattmillerai@users.noreply.github.com>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
Co-authored-by: GitHub Action <action@github.com>
2026-07-08 00:29:59 +00:00
Benjamin Lu
22ea53fb56 fix(ci): remove out-of-rootDir config files from package tsconfig includes (#13485)
## Summary

Type-aware oxlint rejects `packages/ingest-types/tsconfig.json` and
`packages/object-info-parser/tsconfig.json` as invalid (TS6059): their
`include` lists a root-level config file (`openapi-ts.config.ts` /
`vitest.config.ts`) that sits outside `rootDir: "src"`. This fails the
lint-and-format job with "Invalid tsconfig" on any PR that touches those
packages' src files — currently blocking every auto-generated
ingest-types sync (e.g. #12777).

## Changes

- **What**: Drop the out-of-`rootDir` config-file entries from the two
package tsconfig `include` arrays, matching the other workspace
packages. Repro: `pnpm exec oxlint --type-aware
packages/ingest-types/src/index.ts` fails before, passes after; the
config files themselves still lint clean.

## Review Focus

Neither package emits a build, so `rootDir`/`outDir` are
editor/lint-only; excluding the config files from the project has no
runtime effect (vitest/openapi-ts load their configs directly).
2026-07-07 17:03:12 -07:00
cloud-code-bot[bot]
9fe5dd51b8 ci: bump cursor-review to github-workflows@df507e6 (#13493)
Automatic SHA bump — `cursor-review.yml` was updated in
`Comfy-Org/github-workflows` at
[`df507e6`](df507e6bae).
_Opened by the `bump-cursor-review-callers` workflow._

Co-authored-by: cloud-code-bot[bot] <234529496+cloud-code-bot[bot]@users.noreply.github.com>
2026-07-07 23:06:59 +00:00
Wei Hai
747f76db76 ci: auto-merge approved, green backport PRs (#13390)
## Problem
`pr-backport.yaml` opens each backport PR (labelled `backport`) and
calls `gh pr merge --auto --squash`. GitHub's `--auto` only takes effect
when the repository's **"Allow auto-merge"** setting is enabled — it's
currently off, so that call is a silent no-op (swallowed by its `|| echo
"::warning::…"`). The result: every backport PR sits unmerged until
someone manually clicks merge, even when it's already approved with
green checks.

## What this does
Adds `.github/workflows/backport-auto-merge.yaml`, which completes the
merge directly — a plain `gh pr merge --squash` (which does **not**
depend on the "Allow auto-merge" setting) — once GitHub reports the PR
ready to merge.

Ready = `reviewDecision == APPROVED` **and** `mergeStateStatus` is
`CLEAN` or `UNSTABLE`. `UNSTABLE` means the required checks passed but a
*non-required* check is still pending/failing — GitHub still permits
that merge, and gating on `CLEAN` alone would leave backports stuck
behind slow/flaky non-required checks (Socket, codecov, perf, storybook,
etc.).

**Branch protection stays the real gate.** The `core/**` / `cloud/**`
ruleset unconditionally requires an approval + the required checks and
can't be bypassed, and GitHub's merge API re-enforces it at merge time —
so this workflow can only ever finish a merge that already satisfies
those rules. The eligibility check just avoids pointless attempts.

## Design notes
- **Merges with `PR_GH_TOKEN`, not the default token**, on purpose: a
merge by the default `GITHUB_TOKEN` does not emit the `pull_request:
closed` event, which would silently starve `cloud-backport-tag.yaml` (it
creates the `cloud/vX.Y.Z` tag on that event).
- **Triggers:** review submission + check-suite completion (low
latency), plus a 30-min sweep as a backstop for cases the events miss.
- **Never checks out PR code** (no untrusted-code path); only reads PR
metadata via the API. `permissions` on the default token are read-only.
- **Idempotent, bounded merge loop:** treats an already-merged PR (e.g.
a concurrent run or a human) as success, so it won't post a false
failure comment.
- Leaves the existing conflict path in `pr-backport.yaml` untouched
(conflicts never create a PR, so there's nothing here to act on).

## Validation
YAML parses; `actionlint` (with shellcheck) and `zizmor` both clean (0
findings).

## Before relying on it
- Confirm the org allows this workflow to run/merge (Actions policy) —
the merge uses a PAT so it shouldn't depend on the "Actions can approve
PRs" toggle, but worth verifying.
- First real backport: confirm it merges on ready and that
`cloud-backport-tag.yaml` then fires and creates the tag.
2026-07-07 16:50:23 +00:00
Mobeen Abdullah
386460afef fix(website): center button labels by tuning ppformula-text-center (#13445)
## Summary

Vertically center button/badge/nav labels by tuning the shared
`ppformula-text-center` utility from `top: 0.19em` to `top: 0.1em`.

## The alignment issue

PP Formula (our brand font) has asymmetric vertical metrics: its caps
sit high in the line box, so a naively centered label looks too high.
`ppformula-text-center` compensates by nudging the label down with
`position: relative; top: <em>` (a purely visual shift, it does not
change the element's box, so button/badge sizes are unaffected).

The value was `0.19em`, which **over-corrected**: the glyph ink ended up
~1.4px **below** center on every button, so labels read slightly low.
Measuring the actual glyph ink (canvas `measureText`
`actualBoundingBox*`) showed ~**0.09-0.10em** centers uppercase labels;
`0.1em` lands the ink within ~0.1px of center.

## Why it's safe (verified)

This utility is used site-wide (Button, Badge, ButtonPill, ButtonMask,
BrandButton, nav triggers, section labels). Because it's a
`position:relative` nudge, there is **no layout/box-size change**
anywhere. I measured glyph-ink centering across **12 pages** (home,
cloud, cloud/pricing, download, careers, customers, demos, enterprise,
api, mcp, gallery, learning):

- Buttons/badges/pills went from ~1.37px low to **~0.11px** (centered).
- **Nothing regressed** (no element pushed too high).
- The handful of numeric "outliers" were `text-transform: uppercase`
measurement artifacts (source text with descenders that don't render);
confirmed visually as centered.

## Changes

- **What**: `apps/website/src/styles/global.css` —
`ppformula-text-center` `top: 0.19em` → `0.1em` (one line).

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-07-07 20:37:32 +05:00
Mobeen Abdullah
5cf647d183 feat(website): add GPT Image 2 to cloud page model list (#13431)
## Summary

Add GPT Image 2 to the `/cloud` "AI models" section, and apply the
design review polish from Bert and June on the same section and the
neighbouring cloud-page cards.

## Changes

- **GPT Image 2 card**: 6th card in `AIModelsSection` (workflow video on
`media.comfy.org`, OpenAI badge reused from `packages/design-system`),
new `cloud.aiModels.card.gptImage2` i18n key (en + zh-CN), and GPT Image
2 added to the `cloud.reason.2.description` partner-model list.
- **AI models layout**: the six cards are now equal 1:1 squares in one
shared grey container (was a per-card treatment, then simplified to a
single container per design), with a corner arrow affordance on each.
- **Audience cards** (`AudienceSection`): the creators / teams cards now
link to `cloud.comfy.org` with the same corner arrow (highlights on card
hover).
- **Reusable `CardArrow`**: extracted the corner arrow into a shared,
decorative (`aria-hidden`) component used by both sections;
`hover="group"` (card hover) for audience, self-hover for the model
cards so it doesn't double up with the provider badge.
- **`ProductCard` CTA**: swapped the hand-rolled pill `<span>` for the
shared `Button` (`as="span"`) so the label is vertically centered (fixes
Bert's off-centre text) without nesting an anchor inside the card link.

## Split out of this PR

- **Button label centering** (the global `ppformula-text-center` tweak)
→ separate PR #13445, so the site-wide change is reviewed in isolation.
- **Pricing banner frame fix** reverted here; it belongs in Michael's
upcoming pricing PR (team tier, edu billing, FAQ). `PricingSection.vue`
shows only an automatic Tailwind class-order reformat from the
pre-commit hook, no behaviour change.

## Review focus

- The single-container AI models layout and the `CardArrow` hover
behaviour (group vs self).
- `Button as="span"` inside the `ProductCard` link (avoids nested
`<a>`).

Linear: FE-423

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-07-07 20:19:54 +05:00
Mobeen Abdullah
fe1fc8baa6 fix(website): standardize favicon to square brand icon (#13467)
## Summary

Standardize the marketing-site favicons to the square, full-bleed brand
mark (ink background, yellow C) so each platform applies its own corner
mask instead of double-rounding a pre-rounded asset.

## Changes

- **What**: Replace three files in `apps/website/public/`:
- `favicon.svg` — was a 57 KB RealFaviconGenerator wrapper around an
embedded PNG; now a 1.2 KB vector of the square mark.
- `favicon-96x96.png` and `apple-touch-icon.png` — regenerated square.
The apple-touch icon previously had transparent rounded corners, which
iOS composites onto a white tile; it is now full-bleed.
- `favicon.ico` and the `web-app-manifest-*.png` files were already the
square mark, so they are left unchanged.

## Review Focus

- Favicons cache aggressively (browser + CDN, and these paths are marked
`immutable` in `vercel.json`), so verify the preview with a hard refresh
or a fresh profile.
- Part of the org-wide favicon standardization (FE-705). Companion PRs
update the workflows hub, docs, and registry favicons to the same mark.
Design direction (square, not rounded) confirmed by Bert.

## Screenshots

The before/after for each binary is visible inline in the Files changed
tab. Square ink + yellow C, no transparency, sharp corners.
2026-07-07 20:18:12 +05:00
Benjamin Lu
3e4dd59e5f feat(navigation): add opt-in strip-on-capture to preserved-query tracker (#13465)
## What

- `installPreservedQueryTracker` definitions accept an opt-in
`stripAfterCapture` flag: the marked keys are captured into the
sessionStorage stash and removed from the URL before the navigation
completes (single guard redirect at the decoded query-object level;
push/replace semantics are inherited from the original navigation, and
vue-router force-replaces the initial one).
- `preservedQueryManager` now captures the first non-empty string
element of repeated (array-valued) params instead of silently dropping
them.
- New real-router test suite for the tracker (createRouter +
createMemoryHistory, no router mocks), including a history-depth test
pinning the push/replace inheritance; manager tests extended for the
array/junk-value cases.

## Why

One-time secrets in query params (first consumer: desktop login codes,
GTM-93) must not linger in the visible URL, browser history,
`previousFullPath` redirects, or telemetry. Stripping after navigation —
what each loader does ad hoc today — leaves a window and forces
per-feature URL scrubbing; #13418 originally needed a hand-rolled
encoding-aware string parser in three places. Stripping at capture time,
at the decoded query-object level, makes the stash the only carrier and
lets vue-router round-trip the surviving params' encoding itself.

Capability only — no existing namespace opts in; behavior is unchanged
for all current definitions. `stripAfterCapture`'s contract is
documented on the option: strip-marked keys must never be read from
`route.query` by later guards or views; the stash is the only
post-capture source.

## Landing order

Independent of everything else; #13418 stacks on this branch.
2026-07-07 07:08:11 +00:00
Benjamin Lu
e25e0f2e16 refactor: simplify slot link drop finalization (#13471)
## Summary

Small cleanup in `useSlotLinkInteraction.ts`, no behavior change:

- Removed a duplicated `raf.flush()` in `finishInteraction` (it was
called twice back-to-back).
- Collapsed four single-line `attempt*` alias closures in
`connectByPriority` into a direct short-circuit chain, preserving the
same evaluation order:

  ```ts
  return (
    tryConnectToCandidate(snappedCandidate) ||
    tryConnectToCandidate(domSlotCandidate) ||
    tryConnectToCandidate(nodeSurfaceSlotCandidate) ||
    tryConnectViaRerouteAtPointer()
  )
  ```

The closures added no behavior beyond renaming the calls (AGENTS.md rule
26), and `||` gives the same first-truthy-wins semantics as the previous
`if (attempt()) return true` ladder.

Verification not rerun after rebasing onto `Comfy-Org/main`; original
branch reported `pnpm typecheck`, `pnpm lint`, `pnpm format:check`,
`pnpm knip`, and the existing `useSlotLinkInteraction` unit tests
passing.

Link to Devin session:
https://app.devin.ai/sessions/1351ff5174494106a7a688777554f387
Requested by: @benceruleanlu

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-07 07:05:36 +00:00
Benjamin Lu
2ee91c30ee fix: report CLA check in merge queue (#13477)
Report the CLA Assistant required check for merge queue commits so
queued PRs do not wait indefinitely on a check that ran on the PR head.
2026-07-07 07:57:02 +00:00
Alexis Rolland
854770d305 ci: Update CLA workflow to build author-only allowlist (#13378)
## Summary

Update CLA workflow to build a dynamic `allowlist` that includes
everyone except the author of the PR. This relaxes the CLA signature
requirement so that it is limited to the PR author only. By signing, the
author confirms he gots approval from other contributors.

## Changes

- **What**: `cla.yml`

## Screenshots (if applicable)

<img width="1831" height="756"
alt="{B6F6C23D-EC2E-4BB3-A288-99B6087F4CAC}"
src="https://github.com/user-attachments/assets/62c04465-d1a3-4ddb-bfe7-950a29a802c4"
/>
2026-07-06 19:29:59 -07:00
190 changed files with 6624 additions and 7159 deletions

View File

@@ -0,0 +1,197 @@
---
name: Backport Auto-Merge
# Completes the merge of backport PRs once they are approved and their required
# checks pass.
#
# Background: pr-backport.yaml opens each backport PR (labelled `backport`) and
# calls `gh pr merge --auto`, which relies on the repo-level "Allow auto-merge"
# setting. That setting is off, so `--auto` is a silent no-op and backport PRs
# sit unmerged until a human clicks merge. This workflow performs the merge
# directly (a plain `gh pr merge --squash`, which does not depend on that
# setting) once GitHub itself reports the PR as ready to merge.
#
# Safety: branch protection on core/** and cloud/** is the hard gate — it
# unconditionally requires an approval + the required status checks and cannot
# be bypassed, and GitHub's merge API re-enforces it at merge time. This
# workflow can only ever complete a merge that already satisfies those rules;
# the eligibility check below only avoids pointless merge attempts.
#
# The merge uses PR_GH_TOKEN (not the default GITHUB_TOKEN) on purpose: a merge
# performed by the default token does not emit events that trigger other
# workflows, which would silently starve cloud-backport-tag.yaml (it runs on the
# backport PR's `pull_request: closed` event to create the release tag).
on:
# Fires when someone approves — if the required checks are already green, the
# PR merges immediately.
pull_request_review:
types: [submitted]
# Primary catch for the "approved first, checks went green later" case, plus a
# general backstop. A `check_suite`/`workflow_run` trigger would react faster to
# checks completing, but GitHub suppresses `check_suite` events for its own
# Actions suites (so it wouldn't fire for this repo's CI), and `workflow_run` is
# a secrets-bearing "dangerous" trigger we don't want on a public repo for a
# non-latency-critical task. Backports wait hours today, so a short sweep is a
# large improvement and needs neither.
schedule:
- cron: '*/15 * * * *'
# Only constrains the default github.token (used for read-only PR lookups below).
# It does NOT constrain PR_GH_TOKEN, whose authority is fixed by its own scopes.
permissions:
contents: read # read-only; required for gh api / gh pr list to resolve candidates
pull-requests: read # read-only; required for gh pr view eligibility checks
# Serialize runs that act on the same PR (review events keyed by PR number; all
# scheduled sweeps share one key). Cross-key overlaps are still possible but
# harmless: the merge loop treats an already-merged PR as success (idempotent).
concurrency:
group: backport-auto-merge-${{ github.event.pull_request.number || 'sweep' }}
cancel-in-progress: false
jobs:
merge:
name: Merge eligible backport PRs
# Skip review events that can't possibly make a PR mergeable — non-approval
# reviews, or reviews on non-backport PRs (most reviews in the repo) — before
# spending any API call. Schedule sweeps always proceed. The per-PR
# eligibility checks in the job still re-verify the label and decision from
# live state.
if: github.event_name != 'pull_request_review' || (github.event.review.state == 'approved' && contains(github.event.pull_request.labels.*.name, 'backport'))
runs-on: ubuntu-latest
permissions:
contents: read # read-only PR/commit lookups via the default token
pull-requests: read # read-only PR metadata via the default token
steps:
- name: Collect candidate backport PRs
id: candidates
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
EVENT_NAME: ${{ github.event_name }}
PR_FROM_REVIEW: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
numbers=""
case "$EVENT_NAME" in
pull_request_review)
numbers="$PR_FROM_REVIEW"
;;
schedule)
# Sweep every open backport PR.
numbers=$(gh pr list --repo "$GH_REPO" --state open --label backport \
--limit 100 --json number --jq '.[].number')
;;
esac
# De-duplicate and emit space-separated, digit-only tokens.
numbers=$(echo "$numbers" | tr ' ' '\n' | grep -E '^[0-9]+$' | sort -u | tr '\n' ' ' || true)
echo "numbers=${numbers}" >> "$GITHUB_OUTPUT"
echo "Candidate PRs: '${numbers:-<none>}'"
- name: Merge eligible backport PRs
if: steps.candidates.outputs.numbers != ''
env:
GH_REPO: ${{ github.repository }}
# Read with the default token; merge with PR_GH_TOKEN so the merge emits
# the events that downstream workflows (cloud-backport-tag.yaml) rely on.
READ_TOKEN: ${{ github.token }}
MERGE_TOKEN: ${{ secrets.PR_GH_TOKEN }}
CANDIDATES: ${{ steps.candidates.outputs.numbers }}
run: |
set -euo pipefail
is_merged() {
[ "$(GH_TOKEN="$READ_TOKEN" gh pr view "$1" --repo "$GH_REPO" --json merged --jq '.merged' 2>/dev/null || echo false)" = "true" ]
}
for pr in $CANDIDATES; do
echo "::group::PR #${pr}"
info=$(GH_TOKEN="$READ_TOKEN" gh pr view "$pr" --repo "$GH_REPO" \
--json number,state,isDraft,labels,baseRefName,reviewDecision,mergeStateStatus 2>/dev/null || echo '')
if [ -z "$info" ]; then
echo "Could not read PR #${pr} — skipping."; echo "::endgroup::"; continue
fi
state=$(echo "$info" | jq -r '.state')
is_draft=$(echo "$info" | jq -r '.isDraft')
is_backport=$(echo "$info" | jq -r '[.labels[].name] | any(. == "backport")')
base=$(echo "$info" | jq -r '.baseRefName')
review=$(echo "$info" | jq -r '.reviewDecision')
merge_state=$(echo "$info" | jq -r '.mergeStateStatus')
# Only ever act on open, non-draft, backport-labelled PRs targeting a
# protected release branch.
if [ "$state" != "OPEN" ] || [ "$is_draft" != "false" ] || [ "$is_backport" != "true" ]; then
echo "Not an actionable backport PR (state=$state draft=$is_draft backport=$is_backport) — skipping."
echo "::endgroup::"; continue
fi
case "$base" in
cloud/*|core/*) : ;;
*) echo "Base '$base' is not a release branch — skipping."; echo "::endgroup::"; continue ;;
esac
# Ready = approved AND GitHub says it's mergeable with required checks green.
# CLEAN = approved, all required checks green, mergeable, no conflict.
# UNSTABLE = same, but a NON-required check is pending/failing — GitHub
# still allows the merge, so we do too (matches what a human
# clicking "Squash and merge" can do; required checks are the
# only merge gate per the ruleset). Requiring CLEAN alone would
# stick forever behind flaky/slow non-required checks.
# Any other state (BLOCKED/DIRTY/BEHIND/UNKNOWN/...) => not ready; re-checked
# by a later event or the next sweep.
if [ "$review" != "APPROVED" ] || { [ "$merge_state" != "CLEAN" ] && [ "$merge_state" != "UNSTABLE" ]; }; then
echo "Not yet ready (reviewDecision=$review mergeStateStatus=$merge_state) — will re-check later."
echo "::endgroup::"; continue
fi
echo "PR #${pr} is ready — attempting squash merge."
attempt=0
max=3
merged=false
while [ "$attempt" -lt "$max" ]; do
attempt=$((attempt + 1))
# A concurrent run (or a human) may have merged it already.
if is_merged "$pr"; then merged=true; break; fi
if out=$(GH_TOKEN="$MERGE_TOKEN" gh pr merge "$pr" --repo "$GH_REPO" --squash 2>&1); then
merged=true; break
fi
echo "Merge attempt ${attempt}/${max} failed: ${out}"
# No sleep after the final attempt.
[ "$attempt" -lt "$max" ] && sleep $((attempt * 15))
done
# Final reconciliation: a failed merge command may just mean a concurrent
# run won the race — don't post a false failure if the PR is in fact merged.
if [ "$merged" != "true" ] && is_merged "$pr"; then merged=true; fi
if [ "$merged" = "true" ]; then
echo "PR #${pr} merged."
else
echo "::warning::PR #${pr} looked ready but did not merge after ${max} attempts."
# Avoid spamming a persistently-stuck PR: only re-warn if the last
# warning (identified by its marker) is more than an hour old.
marker='<!-- backport-auto-merge:merge-failed -->'
# `gh api --paginate` emits one JSON array per page; `--jq` would run
# per page (missing the true latest across pages), so slurp all pages
# into one array first and filter with a separate jq pass.
last_warned=$(GH_TOKEN="$READ_TOKEN" gh api "repos/${GH_REPO}/issues/${pr}/comments" --paginate 2>/dev/null \
| jq -s "[.[][] | select(.body | contains(\"${marker}\"))] | sort_by(.created_at) | last | .created_at // empty") || last_warned=''
stale=true
if [ -n "$last_warned" ]; then
last_epoch=$(date -d "$last_warned" +%s 2>/dev/null || echo 0)
now_epoch=$(date -u +%s)
[ $((now_epoch - last_epoch)) -lt 3600 ] && stale=false
fi
if [ "$stale" = "true" ]; then
body=$(printf '%s\n\n%s' \
"This backport PR is approved and its required checks are green, but automatic merge failed after ${max} attempts. Please merge manually or investigate (possible branch-protection mismatch)." \
"$marker")
GH_TOKEN="$MERGE_TOKEN" gh pr comment "$pr" --repo "$GH_REPO" --body "$body" || true
else
echo "Already warned within the last hour — skipping duplicate comment."
fi
fi
echo "::endgroup::"
done

View File

@@ -6,6 +6,7 @@ on:
pull_request_target:
types: [opened, synchronize, closed]
merge_group:
types: [checks_requested]
permissions:
actions: write
@@ -17,13 +18,45 @@ jobs:
cla-assistant:
runs-on: ubuntu-latest
steps:
- name: CLA already verified before merge queue
if: github.event_name == 'merge_group'
run: echo "CLA is checked on the pull request before it enters merge queue."
# The CLA action normally requires every commit author in a PR to sign.
# We only want the PR author to sign, so we allowlist all other committers
# by computing them from the PR's commits and excluding the PR author.
- name: Build author-only allowlist
id: allowlist
if: >
github.event_name == 'pull_request_target' ||
(github.event_name == 'issue_comment' && github.event.issue.pull_request && (
github.event.comment.body == 'recheck' ||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
))
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
PR_AUTHOR: ${{ github.event.pull_request.user.login || github.event.issue.user.login }}
BASE_ALLOWLIST: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,github-actions[bot],Glary Bot,Glary-Bot,*[bot]
run: |
others=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/commits" --paginate \
--jq '.[] | (.author.login // empty), (.committer.login // empty)' \
| sort -u | grep -vix "${PR_AUTHOR}" | paste -sd, -)
if [ -n "$others" ]; then
echo "allowlist=${BASE_ALLOWLIST},${others}" >> "$GITHUB_OUTPUT"
else
echo "allowlist=${BASE_ALLOWLIST}" >> "$GITHUB_OUTPUT"
fi
- name: CLA Assistant
# Run on PR events, on "recheck" comment, or when someone posts the exact signing phrase.
# IMPORTANT: this phrase must match `custom-pr-sign-comment` below.
if: >
github.event_name == 'pull_request_target' ||
github.event.comment.body == 'recheck' ||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
(github.event_name == 'issue_comment' && github.event.issue.pull_request && (
github.event.comment.body == 'recheck' ||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
))
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -39,9 +72,10 @@ jobs:
path-to-signatures: signatures/cla.json
branch: main
# Allowlist bots so they don't need to sign (optional, comma-separated).
# Only the PR author must sign: bots plus every non-author committer
# are allowlisted via the "Build author-only allowlist" step above.
# *[bot] is a catch-all for any GitHub App bot account.
allowlist: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,Glary Bot,Glary-Bot,*[bot]
allowlist: ${{ steps.allowlist.outputs.allowlist }}
# Custom PR comment messages
custom-notsigned-prcomment: |

View File

@@ -29,7 +29,7 @@ jobs:
# SHA-pinned per zizmor `unpinned-uses: hash-pin`. Bump this SHA to pick up
# upstream changes; keep `workflows_ref` matching so prompts/scripts load
# from the same commit as the workflow definition.
uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@047ca48febe3a6647608ed2e0c4331b491cb9d6a # github-workflows#9
uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@df507e6bae179c567ad3849370f99dae588985dc # github-workflows main (df507e6)
with:
# Overriding diff_excludes replaces the reusable default wholesale, so
# this restates the generated/vendored defaults and adds this repo's heavy
@@ -48,7 +48,7 @@ jobs:
:!**/*-snapshots/**
:!src/workbench/extensions/manager/types/generatedManagerTypes.ts
# Load the prompts/scripts from the same ref as `uses:`.
workflows_ref: 047ca48febe3a6647608ed2e0c4331b491cb9d6a
workflows_ref: df507e6bae179c567ad3849370f99dae588985dc
secrets:
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
# Optional — enables start/complete Slack DMs to the triggerer.

View File

@@ -40,7 +40,7 @@ test.describe('Cloud page @smoke', () => {
}
})
test('AIModelsSection heading and 5 model cards are visible', async ({
test('AIModelsSection heading and 6 model cards are visible', async ({
page
}) => {
const heading = page.getByRole('heading', { name: /leading AI models/i })
@@ -49,7 +49,7 @@ test.describe('Cloud page @smoke', () => {
const section = heading.locator('xpath=ancestor::section')
const grid = section.locator('.grid')
const modelCards = grid.locator('a[href="https://comfy.org/workflows"]')
await expect(modelCards).toHaveCount(5)
await expect(modelCards).toHaveCount(6)
})
test('AIModelsSection CTA links to workflows', async ({ page }) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 938 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,10 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1483_15836)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M196.373 184.704V136.491C196.373 132.437 197.909 129.387 201.451 127.36L298.368 71.552C311.573 63.936 327.296 60.3947 343.531 60.3947C404.416 60.3947 442.987 107.584 442.987 157.803C442.987 161.365 442.987 165.419 442.475 169.472L341.995 110.613C339.266 108.876 336.099 107.954 332.864 107.954C329.629 107.954 326.462 108.876 323.733 110.613L196.373 184.704ZM422.699 372.437V257.28C422.699 250.176 419.648 245.12 413.547 241.557L286.187 167.467L327.787 143.616C329.294 142.624 331.059 142.095 332.864 142.095C334.669 142.095 336.434 142.624 337.941 143.616L434.859 199.445C462.784 215.659 481.557 250.176 481.557 283.669C481.557 322.24 458.731 357.76 422.677 372.48L422.699 372.437ZM166.443 270.997L124.843 246.635C121.28 244.608 119.744 241.557 119.744 237.504V125.845C119.744 71.552 161.344 30.4427 217.685 30.4427C239.019 30.4427 258.795 37.5467 275.541 50.24L175.573 108.096C169.493 111.637 166.443 116.715 166.443 123.819V270.976V270.997ZM256 322.731L196.373 289.237V218.197L256 184.704L315.627 218.197V289.237L256 322.731ZM294.315 476.971C272.981 476.971 253.205 469.888 236.459 457.195L336.427 399.339C342.507 395.797 345.557 390.72 345.557 383.616V236.459L387.669 260.821C391.232 262.848 392.747 265.899 392.747 269.952V381.589C392.747 435.883 350.635 476.971 294.315 476.971ZM174.059 363.84L77.12 308.011C49.216 291.776 30.4427 257.28 30.4427 223.787C30.3769 204.756 35.9917 186.138 46.5684 170.317C57.1451 154.495 72.2025 142.19 89.8133 134.976V250.667C89.8133 257.771 92.864 262.848 98.944 266.411L225.813 339.989L184.213 363.84C182.707 364.835 180.941 365.365 179.136 365.365C177.331 365.365 175.565 364.835 174.059 363.84ZM168.469 447.04C111.125 447.04 69.0133 403.925 69.0133 350.635C69.0133 346.581 69.5253 342.528 70.016 338.475L169.984 396.288C176.085 399.851 182.165 399.851 188.245 396.288L315.605 322.731V370.944C315.605 374.997 314.112 378.048 310.549 380.075L213.632 435.883C200.427 443.499 184.704 447.04 168.469 447.04ZM294.315 507.413C323.553 507.416 351.895 497.319 374.547 478.831C397.198 460.343 412.768 434.598 418.624 405.952C475.456 391.232 512 337.92 512 283.648C512 248.128 496.789 213.632 469.376 188.757C471.915 178.091 473.429 167.445 473.429 156.8C473.429 84.2453 414.571 29.9307 346.581 29.9307C332.885 29.9307 319.701 31.9573 306.475 36.544C282.795 13.2354 250.933 0.118797 217.707 1.37049e-07C188.465 -0.00135846 160.121 10.0985 137.469 28.5908C114.817 47.0831 99.2486 72.8325 93.3973 101.483C36.544 116.203 0 169.493 0 223.787C0 259.328 15.2107 293.824 42.624 318.677C40.0853 329.344 38.5707 340.011 38.5707 350.656C38.5707 423.211 97.4293 477.504 165.419 477.504C179.115 477.504 192.299 475.477 205.525 470.912C229.208 494.23 261.08 507.347 294.315 507.456V507.413Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_1483_15836">
<rect width="512" height="512" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { ChevronRight } from '@lucide/vue'
import type { HTMLAttributes } from 'vue'
const { hover = 'self', class: className } = defineProps<{
hover?: 'self' | 'group'
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
:class="
cn(
'flex size-10 items-center justify-center rounded-2xl bg-white/20 text-white backdrop-blur-sm transition-colors',
hover === 'group'
? 'group-hover:bg-primary-comfy-yellow group-hover:text-primary-comfy-ink'
: 'hover:bg-primary-comfy-yellow hover:text-primary-comfy-ink',
className
)
"
aria-hidden="true"
>
<ChevronRight class="size-5" :stroke-width="2" />
</div>
</template>

View File

@@ -33,7 +33,7 @@ const ctaButtons = [
<template>
<nav
class="fixed inset-x-0 top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
class="sticky top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
aria-label="Main navigation"
>
<a

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import Button from '../ui/button/Button.vue'
const { title, description, cta, href, bg } = defineProps<{
title: string
description: string
@@ -28,11 +30,14 @@ const { title, description, cta, href, bg } = defineProps<{
<p class="text-sm text-white/70">
{{ description }}
</p>
<span
class="bg-primary-comfy-yellow text-primary-comfy-ink mt-4 inline-block rounded-xl px-4 py-2 text-xs font-bold tracking-wide"
<Button
as="span"
variant="default"
size="sm"
class="mt-4 h-auto whitespace-normal"
>
{{ cta }}
</span>
</Button>
</div>
</a>
</template>

View File

@@ -1,7 +1,9 @@
<script setup lang="ts">
import type { Locale } from '../../../i18n/translations'
import { externalLinks } from '../../../config/routes'
import { t } from '../../../i18n/translations'
import CardArrow from '../../common/CardArrow.vue'
import GlassCard from '../../common/GlassCard.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
@@ -27,7 +29,7 @@ const cards = [
<template>
<section class="max-w-9xl mx-auto px-4 pt-24 lg:px-20 lg:pt-40">
<h2
class="text-primary-comfy-canvas text-3.5xl/tight mx-auto max-w-3xl text-center font-light lg:text-5xl/tight"
class="text-3.5xl/tight mx-auto max-w-3xl text-center font-light text-primary-comfy-canvas lg:text-5xl/tight"
>
{{ headingParts[0]
}}<span class="text-white">{{
@@ -37,10 +39,11 @@ const cards = [
</h2>
<GlassCard class="mt-12 grid grid-cols-1 gap-6 lg:mt-20 lg:grid-cols-2">
<div
<a
v-for="card in cards"
:key="card.labelKey"
class="bg-primary-comfy-ink rounded-4.5xl overflow-hidden"
:href="externalLinks.cloud"
class="group rounded-4.5xl block overflow-hidden bg-primary-comfy-ink"
>
<img
:src="card.image"
@@ -51,23 +54,27 @@ const cards = [
/>
<div class="mt-8 p-6">
<p
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
>
{{ t(card.labelKey, locale) }}
</p>
<div class="flex items-center justify-between gap-4">
<p
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
>
{{ t(card.labelKey, locale) }}
</p>
<CardArrow hover="group" class="shrink-0" />
</div>
<h3
class="text-primary-comfy-canvas mt-8 text-3xl/tight font-light whitespace-pre-line"
class="mt-8 text-3xl/tight font-light whitespace-pre-line text-primary-comfy-canvas"
>
{{ t(card.titleKey, locale) }}
</h3>
<p class="text-primary-comfy-canvas mt-8 text-base/normal">
<p class="mt-8 text-base/normal text-primary-comfy-canvas">
{{ t(card.descriptionKey, locale) }}
</p>
</div>
</div>
</a>
</GlassCard>
</section>
</template>

View File

@@ -17,18 +17,18 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
>
<div class="max-w-2xl">
<h2
class="text-primary-comfy-ink text-2xl/tight font-medium lg:text-3xl/tight"
class="text-2xl/tight font-medium text-primary-comfy-ink lg:text-3xl/tight"
>
{{ t('cloud.pricing.title', locale) }}
</h2>
<p class="text-primary-comfy-ink mt-4 text-base">
<p class="mt-4 text-base text-primary-comfy-ink">
{{ t('cloud.pricing.description', locale) }}
</p>
<p
v-if="SHOW_FREE_TIER"
class="text-primary-comfy-ink mt-4 text-base font-bold"
class="mt-4 text-base font-bold text-primary-comfy-ink"
>
{{ t('cloud.pricing.tagline', locale) }}
</p>
@@ -36,7 +36,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
<a
:href="getRoutes(locale).cloudPricing"
class="bg-primary-comfy-ink text-primary-comfy-yellow shrink-0 rounded-2xl px-6 py-3 text-center text-sm font-semibold transition-opacity hover:opacity-90"
class="text-primary-comfy-yellow shrink-0 rounded-2xl bg-primary-comfy-ink px-6 py-3 text-center text-sm font-semibold transition-opacity hover:opacity-90"
>
{{ t('cloud.pricing.cta', locale) }}
</a>

View File

@@ -6,6 +6,7 @@ import type { Locale } from '../../../i18n/translations'
import { externalLinks } from '../../../config/routes'
import { t } from '../../../i18n/translations'
import BrandButton from '../../common/BrandButton.vue'
import CardArrow from '../../common/CardArrow.vue'
type ModelCard = {
titleKey:
@@ -14,11 +15,10 @@ type ModelCard = {
| 'cloud.aiModels.card.seedance20'
| 'cloud.aiModels.card.qwenImageEdit'
| 'cloud.aiModels.card.wan22TextToVideo'
| 'cloud.aiModels.card.gptImage2'
imageSrc: string
badgeIcon: string
badgeClass: string
layoutClass: string
objectPosition?: string
}
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
@@ -32,48 +32,45 @@ const modelCards: ModelCard[] = [
imageSrc:
'https://media.comfy.org/website/cloud/ai-models/seedance-20.webm',
badgeIcon: '/icons/ai-models/bytedance.svg',
badgeClass: `${badgeBase} rounded-2xl`,
layoutClass: 'lg:col-span-6 lg:aspect-[16/7]'
badgeClass: `${badgeBase} rounded-2xl`
},
{
titleKey: 'cloud.aiModels.card.nanoBananaPro',
imageSrc:
'https://media.comfy.org/website/cloud/ai-models/nano-banana-pro.webp',
badgeIcon: '/icons/ai-models/gemini.svg',
badgeClass: `${badgeBase} rounded-2xl`,
layoutClass: 'lg:col-span-6 lg:aspect-[16/7]',
objectPosition: 'center 20%'
badgeClass: `${badgeBase} rounded-2xl`
},
{
titleKey: 'cloud.aiModels.card.grokImagine',
imageSrc: 'https://media.comfy.org/website/cloud/ai-models/grok-video.webm',
badgeIcon: '/icons/ai-models/grok.svg',
badgeClass: `${badgeBase} rounded-2xl`,
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
badgeClass: `${badgeBase} rounded-2xl`
},
{
titleKey: 'cloud.aiModels.card.qwenImageEdit',
imageSrc:
'https://media.comfy.org/website/cloud/ai-models/qwen-image-edit.webp',
badgeIcon: '/icons/ai-models/qwen.svg',
badgeClass: `${badgeBase} rounded-2xl`,
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
badgeClass: `${badgeBase} rounded-2xl`
},
{
titleKey: 'cloud.aiModels.card.wan22TextToVideo',
imageSrc: 'https://media.comfy.org/website/cloud/ai-models/wan-22.webm',
badgeIcon: '/icons/ai-models/wan.svg',
badgeClass: `${badgeBase} rounded-2xl`,
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
badgeClass: `${badgeBase} rounded-2xl`
},
{
titleKey: 'cloud.aiModels.card.gptImage2',
imageSrc:
'https://media.comfy.org/website/cloud/ai-models/gpt-image-2.webm',
badgeIcon: '/icons/ai-models/openai.svg',
badgeClass: `${badgeBase} rounded-2xl`
}
]
function getCardClass(layoutClass: string): string {
return cn(
layoutClass,
'group relative h-72 cursor-pointer overflow-hidden rounded-4xl bg-black/40 lg:h-auto'
)
}
const cardClass =
'group relative h-72 cursor-pointer overflow-hidden rounded-3xl bg-black/40 lg:col-span-4 lg:aspect-square lg:h-auto'
</script>
<template>
@@ -100,23 +97,18 @@ function getCardClass(layoutClass: string): string {
</p>
<div class="mt-16 w-full lg:mt-24">
<div class="rounded-4xl border border-white/12 p-2 lg:p-1.5">
<div class="rounded-4xl bg-white/8 p-2 lg:p-1.5">
<div class="grid grid-cols-1 gap-2 lg:grid-cols-12">
<a
v-for="card in modelCards"
:key="card.titleKey"
:href="externalLinks.workflows"
:class="getCardClass(card.layoutClass)"
:class="cardClass"
>
<video
v-if="card.imageSrc.endsWith('.webm')"
:src="card.imageSrc"
:aria-label="t(card.titleKey, locale)"
:style="
card.objectPosition
? { objectPosition: card.objectPosition }
: undefined
"
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
autoplay
loop
@@ -134,11 +126,6 @@ function getCardClass(layoutClass: string): string {
v-else
:src="card.imageSrc"
:alt="t(card.titleKey, locale)"
:style="
card.objectPosition
? { objectPosition: card.objectPosition }
: undefined
"
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
decoding="async"
@@ -168,10 +155,14 @@ function getCardClass(layoutClass: string): string {
</div>
<p
class="text-primary-warm-white absolute inset-x-6 bottom-6 text-2xl/tight font-light whitespace-pre-line drop-shadow-[0_2px_8px_rgba(0,0,0,0.9)] lg:top-6 lg:right-auto lg:bottom-auto lg:text-3xl"
class="text-primary-warm-white absolute right-20 bottom-6 left-6 text-2xl/tight font-light whitespace-pre-line drop-shadow-[0_2px_8px_rgba(0,0,0,0.9)] lg:top-6 lg:right-auto lg:bottom-auto lg:text-3xl"
>
{{ t(card.titleKey, locale) }}
</p>
<CardArrow
class="absolute right-5 bottom-5 lg:right-6 lg:bottom-6"
/>
</a>
</div>
</div>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { IconButtonVariants } from '.'
import { Primitive } from 'reka-ui'
import { cn } from '@comfyorg/tailwind-utils'
import { iconButtonVariants } from '.'
interface Props extends PrimitiveProps {
variant?: IconButtonVariants['variant']
size?: IconButtonVariants['size']
class?: HTMLAttributes['class']
disabled?: boolean
}
const {
as = 'button',
asChild,
variant,
size,
class: className,
disabled
} = defineProps<Props>()
</script>
<template>
<Primitive
data-slot="icon-button"
:data-variant="variant"
:data-size="size"
:as
:as-child
:disabled
:class="cn(iconButtonVariants({ variant, size }), className)"
>
<slot />
</Primitive>
</template>

View File

@@ -0,0 +1,28 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export const iconButtonVariants = cva(
[
'focus-visible:border-primary-comfy-yellow focus-visible:ring-primary-comfy-yellow/50 inline-flex shrink-0 cursor-pointer items-center justify-center rounded-2xl transition-all duration-200 outline-none focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0'
],
{
variants: {
variant: {
ghost:
'text-primary-warm-white hover:text-primary-comfy-yellow bg-transparent',
outline:
'text-primary-comfy-yellow hover:bg-primary-comfy-yellow border-primary-comfy-yellow border-2 bg-primary-comfy-ink hover:text-primary-comfy-ink'
},
size: {
sm: 'size-8',
default: 'size-10',
lg: 'size-14'
}
},
defaultVariants: {
variant: 'ghost',
size: 'default'
}
}
)
export type IconButtonVariants = VariantProps<typeof iconButtonVariants>

View File

@@ -0,0 +1,75 @@
import { onMounted, ref } from 'vue'
import { BANNER_DISMISS_ATTR, BANNER_STORAGE_KEY } from '../utils/banner'
type ClosedBanners = Record<string, boolean>
function readClosedBanners(): ClosedBanners {
try {
const raw = localStorage.getItem(BANNER_STORAGE_KEY)
return raw ? (JSON.parse(raw) as ClosedBanners) : {}
} catch {
return {}
}
}
function writeClosedBanners(value: ClosedBanners): void {
try {
localStorage.setItem(BANNER_STORAGE_KEY, JSON.stringify(value))
} catch {
// Storage unavailable (private mode / quota) — dismissal just won't persist.
}
}
/** The stable part of a version key (everything before `_v<hash>`). */
function versionPrefix(version: string): string {
const idx = version.lastIndexOf('_v')
return idx === -1 ? version : version.slice(0, idx)
}
/**
* Client-side dismissal persisted in localStorage, keyed by a content-aware
* `version`. The banner renders visible in the static HTML (so non-dismissers
* see no pop-in); an inline pre-hydration script hides an already-dismissed
* banner before paint, and this composable then removes it from the DOM on mount.
*/
export function useBannerDismissal(version: string) {
const isVisible = ref(true)
onMounted(() => {
const stored = readClosedBanners()
const prefix = versionPrefix(version)
// Prune stale versions of THIS banner+locale; keep other banners/locales
// and the current version.
const cleaned: ClosedBanners = Object.create(null) as ClosedBanners
let pruned = false
for (const key of Object.keys(stored)) {
if (versionPrefix(key) !== prefix || key === version) {
cleaned[key] = stored[key]
} else {
pruned = true
}
}
if (pruned) writeClosedBanners(cleaned)
isVisible.value = !cleaned[version]
})
function close(): void {
isVisible.value = false
const stored = readClosedBanners()
stored[version] = true
writeClosedBanners(stored)
}
// Call once the close transition has finished. Sets the pre-paint hide signal
// so the banner doesn't flash back in on a ClientRouter (view-transition)
// navigation — where the inline <head> script does not re-run but <html>
// persists. Deferred to after the animation so the leave transition can play.
function persistHidden(): void {
document.documentElement.setAttribute(BANNER_DISMISS_ATTR, '')
}
return { isVisible, close, persistHidden }
}

View File

@@ -0,0 +1,84 @@
import type { ButtonVariants } from '../components/ui/button'
import type { Locale, TranslationKey } from '../i18n/translations'
import { t } from '../i18n/translations'
import { resolveRel } from '../utils/cta'
// The banner "CMS": a single typed config resolved through i18n at build time.
// `isActive` is the master on/off switch (supersedes the old SHOW_ANNOUNCEMENT_BANNER).
// NOTE: on this static site, `startsAt`/`endsAt` are evaluated at BUILD time — the
// window gates on the last deploy, not the visitor's exact clock.
interface BannerLinkConfig {
readonly href: string
readonly titleKey: TranslationKey
readonly target?: boolean
readonly buttonVariant?: NonNullable<ButtonVariants['variant']>
}
export interface BannerConfig {
readonly id: string
readonly isActive: boolean
readonly startsAt?: string
readonly endsAt?: string
/** Empty/undefined = all locales. */
readonly targetLocales?: readonly Locale[]
/** v1 only supports 'sitewide'. */
readonly targetSections?: readonly string[]
readonly titleKey: TranslationKey
readonly descriptionKey?: TranslationKey
readonly link?: BannerLinkConfig
}
interface BannerLinkData {
readonly href: string
readonly title: string
readonly target?: '_blank'
readonly rel?: string
readonly buttonVariant?: NonNullable<ButtonVariants['variant']>
}
export interface BannerData {
readonly id: string
readonly title: string
readonly description?: string
readonly link?: BannerLinkData
}
export const bannerConfig: BannerConfig = {
id: 'announcement',
isActive: true,
targetSections: ['sitewide'],
titleKey: 'launches.banner.text',
link: {
href: '/mcp',
titleKey: 'launches.banner.cta',
buttonVariant: 'underlineLink'
}
}
/** Resolve a config's i18n keys into display strings for the given locale. */
export function getBannerData(
config: BannerConfig,
locale: Locale
): BannerData {
const { link } = config
const target = link?.target ? '_blank' : undefined
return {
id: config.id,
title: t(config.titleKey, locale),
description: config.descriptionKey
? t(config.descriptionKey, locale)
: undefined,
link: link
? {
href: link.href,
title: t(link.titleKey, locale),
target,
rel: resolveRel({ target: target ?? '_self' }),
buttonVariant: link.buttonVariant
}
: undefined
}
}

View File

@@ -932,9 +932,9 @@ const translations = {
'zh-CN': '所有模型。\n商业许可保证。'
},
'cloud.reason.2.description': {
en: 'Run open-source models like Wan 2.2, Flux, LTX and Qwen alongside partner models like Nano Banana, Seedance, Seedream, Grok, Kling, Hunyuan 3D and more. Every model on Comfy Cloud is cleared for commercial use. No license ambiguity. All through one credit balance.',
en: 'Run open-source models like Wan 2.2, Flux, LTX and Qwen alongside partner models like Nano Banana, Seedance, Seedream, Grok, Kling, Hunyuan 3D, GPT Image 2 and more. Every model on Comfy Cloud is cleared for commercial use. No license ambiguity. All through one credit balance.',
'zh-CN':
'运行 Wan 2.2、Flux、LTX 和 Qwen 等开源模型,以及 Nano Banana、Seedance、Seedream、Grok、Kling、Hunyuan 3D 等合作伙伴模型。Comfy Cloud 上的每个模型都已获得商业使用许可。无许可证歧义。通过统一的积分余额使用。'
'运行 Wan 2.2、Flux、LTX 和 Qwen 等开源模型,以及 Nano Banana、Seedance、Seedream、Grok、Kling、Hunyuan 3D、GPT Image 2 等合作伙伴模型。Comfy Cloud 上的每个模型都已获得商业使用许可。无许可证歧义。通过统一的积分余额使用。'
},
'cloud.reason.2.badge.onlyOn': {
en: 'ONLY ON',
@@ -996,6 +996,10 @@ const translations = {
en: 'Wan 2.2',
'zh-CN': 'Wan 2.2'
},
'cloud.aiModels.card.gptImage2': {
en: 'GPT Image 2',
'zh-CN': 'GPT Image 2'
},
'cloud.aiModels.ctaDesktop': {
en: 'EXPLORE WORKFLOWS WITH THE LATEST MODELS',
'zh-CN': '探索最新模型工作流'
@@ -3979,12 +3983,12 @@ const translations = {
// Launches page (/launches) — subscribe banner
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
'launches.banner.text': {
en: 'Join the live stream. Get answers in real time.',
'zh-CN': '加入直播,实时获得解答。'
en: 'Now turn your agent into a creative technologist.',
'zh-CN': '现在,让你的智能体成为创意技术专家。'
},
'launches.banner.cta': {
en: 'Join livestream',
'zh-CN': '加入直播'
en: 'Start Comfy MCP',
'zh-CN': '启动 Comfy MCP'
},
// Launches page (/launches) — closing CTA

View File

@@ -5,6 +5,14 @@ import '../styles/global.css'
import type { Locale } from '../i18n/translations'
import SiteFooter from '../components/common/SiteFooter.vue'
import HeaderMain from '../components/common/HeaderMain/HeaderMain.vue'
import AnnouncementBanner from '../templates/drops/AnnouncementBanner.vue'
import { bannerConfig, getBannerData } from '../config/banner'
import {
BANNER_DISMISS_ATTR,
BANNER_STORAGE_KEY,
createBannerVersion,
evaluateBannerVisibility
} from '../utils/banner'
import { escapeJsonLd } from '../utils/escapeJsonLd'
import { fetchGitHubStars, formatStarCount } from '../utils/github'
@@ -34,6 +42,15 @@ const locale: Locale = rawLocale === 'zh-CN' ? 'zh-CN' : 'en'
const rawStars = await fetchGitHubStars('Comfy-Org', 'ComfyUI')
const githubStars = rawStars ? formatStarCount(rawStars) : ''
// Announcement banner — build-time visibility gate + content-hash version key.
const bannerData = getBannerData(bannerConfig, locale)
const bannerVisible = evaluateBannerVisibility(bannerConfig, {
currentLocale: locale,
currentSection: 'sitewide',
now: new Date(),
})
const bannerVersion = createBannerVersion(bannerData, locale)
const gtmId = 'GTM-NP9JM6K7'
const gtmEnabled = import.meta.env.PROD
@@ -124,6 +141,25 @@ const websiteJsonLd = {
<ClientRouter />
<slot name="head" />
<!-- Hide an already-dismissed announcement banner before first paint (no flash/shift). -->
{bannerVisible && (
<script
is:inline
define:vars={{
bannerVersion,
storageKey: BANNER_STORAGE_KEY,
dismissAttr: BANNER_DISMISS_ATTR
}}
>
try {
const dismissed = JSON.parse(localStorage.getItem(storageKey) || '{}')
if (dismissed[bannerVersion]) {
document.documentElement.setAttribute(dismissAttr, '')
}
} catch (e) {}
</script>
)}
</head>
<body class="bg-primary-comfy-ink text-white font-formula antialiased overflow-x-clip">
{gtmEnabled && (
@@ -137,8 +173,16 @@ const websiteJsonLd = {
</noscript>
)}
{bannerVisible && (
<AnnouncementBanner
data={bannerData}
version={bannerVersion}
locale={locale}
client:load
/>
)}
<HeaderMain locale={locale} github-stars={githubStars} client:load />
<main class="mt-20 lg:mt-32">
<main>
<slot />
</main>
<SiteFooter locale={locale} client:load />

View File

@@ -3,7 +3,6 @@ import BaseLayout from '../layouts/BaseLayout.astro'
import CtaSection from '../templates/drops/CtaSection.vue'
import DropsSection from '../templates/drops/DropsSection.vue'
import HeroSection from '../templates/drops/HeroSection.vue'
import SubscribeBanner from '../templates/drops/SubscribeBanner.vue'
import { t } from '../i18n/translations'
const locale = 'en' as const
@@ -13,7 +12,6 @@ const locale = 'en' as const
title={t('launches.page.title', locale)}
description={t('launches.page.description', locale)}
>
<SubscribeBanner locale={locale} client:load />
<HeroSection locale={locale} client:load />
<DropsSection locale={locale} />
<CtaSection locale={locale} />

View File

@@ -3,7 +3,6 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
import CtaSection from '../../templates/drops/CtaSection.vue'
import DropsSection from '../../templates/drops/DropsSection.vue'
import HeroSection from '../../templates/drops/HeroSection.vue'
import SubscribeBanner from '../../templates/drops/SubscribeBanner.vue'
import { t } from '../../i18n/translations'
const locale = 'zh-CN' as const
@@ -13,7 +12,6 @@ const locale = 'zh-CN' as const
title={t('launches.page.title', locale)}
description={t('launches.page.description', locale)}
>
<SubscribeBanner locale={locale} client:load />
<HeroSection locale={locale} client:load />
<DropsSection locale={locale} />
<CtaSection locale={locale} />

View File

@@ -70,6 +70,7 @@
--color-secondary-mauve: #4d3762;
--color-destructive: #f44336;
--color-primary-comfy-plum: #49378b;
--color-secondary-deep-plum: #2b2040;
--color-secondary-cool-gray: #3c3c3c;
--color-illustration-forest: #20464c;
--color-transparency-white-t4: rgb(255 255 255 / 0.04);
@@ -93,6 +94,14 @@
initial-value: 0deg;
}
/* Pre-hydration hide for a dismissed announcement banner (set by an inline
script in BaseLayout head) — prevents any flash before Vue hydrates.
The [data-banner-dismissed] literal is BANNER_DISMISS_ATTR in utils/banner.ts;
keep them in sync. */
[data-banner-dismissed] [data-slot='announcement-banner'] {
display: none;
}
@keyframes border-angle-spin {
to {
--border-angle: 360deg;
@@ -248,7 +257,7 @@
@utility ppformula-text-center {
display: inline-block;
position: relative;
top: 0.19em;
top: 0.1em;
}
/* Hide native play-button overlay iOS Safari shows when autoplay is blocked

View File

@@ -0,0 +1,107 @@
<script setup lang="ts">
import { ArrowRight, X } from '@lucide/vue'
import type { BannerData } from '../../config/banner'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import Button from '@/components/ui/button/Button.vue'
import IconButton from '@/components/ui/icon-button/IconButton.vue'
import { useBannerDismissal } from '../../composables/useBannerDismissal'
const {
data,
version,
locale = 'en'
} = defineProps<{
data: BannerData
version: string
locale?: Locale
}>()
const { isVisible, close, persistHidden } = useBannerDismissal(version)
</script>
<template>
<Transition name="banner-collapse" @after-leave="persistHidden">
<div v-if="isVisible" class="banner-collapse grid">
<div class="min-h-0 overflow-hidden">
<div
data-slot="announcement-banner"
class="after:bg-transparency-white-t4 relative flex items-center gap-x-6 px-6 py-4 after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:h-px sm:px-3.5 sm:before:flex-1"
style="
background: linear-gradient(
90deg,
var(--color-primary-comfy-plum) 0%,
var(--color-secondary-deep-plum) 53.85%,
var(--color-secondary-mauve) 100%
);
"
>
<div class="flex flex-wrap items-center gap-x-8 gap-y-2">
<p
class="text-primary-warm-white ppformula-text-center text-sm md:text-base/6"
>
{{ data.title }}
<span v-if="data.description" class="text-primary-warm-white/80">
{{ data.description }}
</span>
</p>
<Button
v-if="data.link"
as="a"
:href="data.link.href"
:target="data.link.target"
:rel="data.link.rel"
:variant="data.link.buttonVariant ?? 'underlineLink'"
size="sm"
>
{{ data.link.title }}
<template #append>
<ArrowRight class="size-4" />
</template>
</Button>
</div>
<div class="flex flex-1 justify-end">
<IconButton
type="button"
:aria-label="t('nav.close', locale)"
@click="close"
>
<X class="size-5" aria-hidden="true" />
</IconButton>
</div>
</div>
</div>
</div>
</Transition>
</template>
<style scoped>
/* Collapse the banner's height (grid 1fr → 0fr) so page content below slides
up smoothly, with a fade. Enter is defined for symmetry; in practice only the
leave (dismiss) runs, since the banner renders present in the static HTML. */
.banner-collapse {
grid-template-rows: 1fr;
}
.banner-collapse-enter-active,
.banner-collapse-leave-active {
transition:
grid-template-rows 300ms ease,
opacity 250ms ease;
}
.banner-collapse-enter-from,
.banner-collapse-leave-to {
grid-template-rows: 0fr;
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.banner-collapse-enter-active,
.banner-collapse-leave-active {
transition: none;
}
}
</style>

View File

@@ -1,61 +0,0 @@
<script setup lang="ts">
import { useTimeoutFn } from '@vueuse/core'
import { onMounted, ref } from 'vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import Button from '@/components/ui/button/Button.vue'
import { resolveRel } from '../../utils/cta'
import { livestream } from './livestream'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const signUpHref = `https://www.youtube.com/watch?v=${livestream.youtubeVideoId}`
const signUpRel = resolveRel({ target: '_blank' })
// Hide once the livestream window closes — both for visitors arriving after
// the event and for visitors whose tab is open when it ends.
const endMs = new Date(livestream.endDateTime).getTime()
const visible = ref(true)
// useTimeoutFn auto-clears on unmount. Arm it client-side only so SSR never
// schedules a long-lived server timer.
const { start } = useTimeoutFn(
() => {
visible.value = false
},
() => Math.max(0, endMs - Date.now()),
{ immediate: false }
)
onMounted(() => {
if (endMs - Date.now() <= 0) {
visible.value = false
} else {
start()
}
})
</script>
<template>
<div v-if="visible" class="px-4">
<div
class="bg-primary-comfy-plum max-w-8xl rounded-5xl text-primary-warm-white mx-auto flex w-full flex-col items-center justify-center gap-2 px-6 py-5 text-center text-sm sm:flex-row sm:gap-4"
>
<p class="ppformula-text-center">
{{ t('launches.banner.text', locale) }}
</p>
<Button
:href="signUpHref"
as="a"
variant="underlineLink"
size="sm"
target="_blank"
:rel="signUpRel"
>
{{ t('launches.banner.cta', locale) }}
</Button>
</div>
</div>
</template>

View File

@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest'
import type { EvaluableBanner } from './banner'
import { createBannerVersion, evaluateBannerVisibility } from './banner'
const base: EvaluableBanner = {
isActive: true,
targetSections: ['sitewide']
}
const ctx = {
currentLocale: 'en',
currentSection: 'sitewide',
now: new Date('2026-07-06T00:00:00Z')
}
describe('evaluateBannerVisibility', () => {
it('shows an active, untargeted, sitewide banner', () => {
expect(evaluateBannerVisibility(base, ctx)).toBe(true)
})
it('hides when inactive', () => {
expect(evaluateBannerVisibility({ ...base, isActive: false }, ctx)).toBe(
false
)
})
it('hides before startsAt and shows within the window', () => {
expect(
evaluateBannerVisibility(
{ ...base, startsAt: '2026-07-10T00:00:00Z' },
ctx
)
).toBe(false)
expect(
evaluateBannerVisibility(
{ ...base, startsAt: '2026-07-01T00:00:00Z' },
ctx
)
).toBe(true)
})
it('hides after endsAt', () => {
expect(
evaluateBannerVisibility({ ...base, endsAt: '2026-07-01T00:00:00Z' }, ctx)
).toBe(false)
expect(
evaluateBannerVisibility({ ...base, endsAt: '2026-07-10T00:00:00Z' }, ctx)
).toBe(true)
})
it('treats an empty targetLocales as "all locales"', () => {
expect(evaluateBannerVisibility({ ...base, targetLocales: [] }, ctx)).toBe(
true
)
})
it('hides when targetLocales excludes the current locale', () => {
expect(
evaluateBannerVisibility({ ...base, targetLocales: ['zh-CN'] }, ctx)
).toBe(false)
expect(
evaluateBannerVisibility({ ...base, targetLocales: ['en', 'zh-CN'] }, ctx)
).toBe(true)
})
it('hides when targetSections does not include the current section', () => {
expect(
evaluateBannerVisibility({ ...base, targetSections: ['checkout'] }, ctx)
).toBe(false)
})
it('hides when targetSections is absent (nothing to match)', () => {
expect(evaluateBannerVisibility({ isActive: true }, ctx)).toBe(false)
})
})
describe('createBannerVersion', () => {
const content = {
id: 'announcement',
title: 'Join the live stream',
link: { href: 'https://x', title: 'Join' }
}
it('is deterministic for identical content', () => {
expect(createBannerVersion(content, 'en')).toBe(
createBannerVersion(content, 'en')
)
})
it('encodes the banner id and locale in the key', () => {
expect(createBannerVersion(content, 'en')).toMatch(
/^announcement_en_v-?\d+$/
)
})
it('changes when the copy changes', () => {
expect(createBannerVersion(content, 'en')).not.toBe(
createBannerVersion({ ...content, title: 'New copy' }, 'en')
)
})
it('differs per locale so one locale edit does not re-show another', () => {
expect(createBannerVersion(content, 'en')).not.toBe(
createBannerVersion(content, 'zh-CN')
)
})
})

View File

@@ -0,0 +1,87 @@
// Pure, framework-agnostic banner logic — no Vue/Astro/config imports so it stays
// trivially unit-testable. Locale/section are plain strings on purpose.
// Shared dismissal storage contract. The pre-hydration script in BaseLayout.astro,
// the useBannerDismissal composable, and the CSS selector in global.css must all
// agree on these literals — keep them here as the single source of truth.
export const BANNER_STORAGE_KEY = 'closedBanners'
export const BANNER_DISMISS_ATTR = 'data-banner-dismissed'
export interface BannerVisibilityContext {
currentLocale: string
currentSection: string
now: Date
}
export interface EvaluableBanner {
isActive: boolean
startsAt?: string
endsAt?: string
targetLocales?: readonly string[]
targetSections?: readonly string[]
}
/**
* Server/build-time visibility gate. Returns false on the FIRST failing check,
* in order: active flag → start window → end window → locale targeting →
* section targeting. An empty/absent `targetLocales` means "all locales".
*/
export function evaluateBannerVisibility(
banner: EvaluableBanner,
ctx: BannerVisibilityContext
): boolean {
if (!banner.isActive) return false
if (
banner.startsAt &&
ctx.now.getTime() < new Date(banner.startsAt).getTime()
)
return false
if (banner.endsAt && ctx.now.getTime() > new Date(banner.endsAt).getTime())
return false
const targetLocales = banner.targetLocales ?? []
if (targetLocales.length > 0 && !targetLocales.includes(ctx.currentLocale))
return false
const targetSections = banner.targetSections ?? []
if (!targetSections.includes(ctx.currentSection)) return false
return true
}
interface BannerLinkContent {
href: string
title: string
target?: string
rel?: string
buttonVariant?: string
}
export interface BannerVersionContent {
id: string
title: string
description?: string
link?: BannerLinkContent
}
/**
* Content-aware version key. Editing the copy changes the hash, so a previously
* dismissed banner re-appears. Keyed per-locale so a zh-CN edit doesn't re-show
* the banner for en visitors. Format: `${content.id}_${locale}_v${hash}`.
*/
export function createBannerVersion(
content: BannerVersionContent,
locale: string
): string {
const contentString = JSON.stringify({
locale,
title: content.title,
description: content.description,
link: content.link
})
let hash = 0
for (const char of contentString) {
hash = Math.imul(hash, 31) + char.charCodeAt(0)
}
return `${content.id}_${locale}_v${hash}`
}

View File

@@ -6,7 +6,7 @@
"nodes": [
{
"id": 3,
"type": "4e7c1a2b-3d5f-4a6b-8c9d-0e1f2a3b4c5d",
"type": "outer-subgraph-with-promoted-missing-model",
"pos": [10, 250],
"size": [400, 200],
"flags": {},
@@ -20,7 +20,7 @@
},
{
"id": 4,
"type": "4e7c1a2b-3d5f-4a6b-8c9d-0e1f2a3b4c5d",
"type": "outer-subgraph-with-promoted-missing-model",
"pos": [450, 250],
"size": [400, 200],
"flags": {},
@@ -38,7 +38,7 @@
"definitions": {
"subgraphs": [
{
"id": "4e7c1a2b-3d5f-4a6b-8c9d-0e1f2a3b4c5d",
"id": "outer-subgraph-with-promoted-missing-model",
"version": 1,
"state": {
"lastGroupId": 0,
@@ -71,7 +71,7 @@
"nodes": [
{
"id": 2,
"type": "5f8d2b3c-4e6a-4b7c-9d0e-1f2a3b4c5d6e",
"type": "inner-subgraph-with-promoted-missing-model",
"pos": [250, 180],
"size": [400, 200],
"flags": {},
@@ -105,7 +105,7 @@
]
},
{
"id": "5f8d2b3c-4e6a-4b7c-9d0e-1f2a3b4c5d6e",
"id": "inner-subgraph-with-promoted-missing-model",
"version": 1,
"state": {
"lastGroupId": 0,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -1270,57 +1270,3 @@ test(
})
}
)
test('Floating reroutes', { tag: '@vue-nodes' }, async ({ comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
const previewNodePos = { position: { x: 800, y: 200 } }
await comfyPage.searchBoxV2.addNode('Preview Image', previewNodePos)
const previewNode =
await comfyPage.vueNodes.getFixtureByTitle('Preview Image')
await test.step('Create floating reroute', async () => {
const reroutePos = { targetPosition: { x: 700, y: 400 } }
await previewNode
.getSlot('images')
.first()
.dragTo(comfyPage.canvas, reroutePos)
await comfyPage.contextMenu.clickLitegraphMenuItem('Add Reroute')
await comfyPage.searchBoxV2.addNode('Load Image')
})
await test.step('Connect node on top of floating link', async () => {
const loadNode = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
await loadNode
.getSlot('IMAGE')
.first()
.dragTo(previewNode.getSlot('images').first())
})
await test.step('Create node from floating reroute', async () => {
await comfyPage.canvas.dragTo(comfyPage.canvas, {
sourcePosition: { x: 680, y: 400 },
targetPosition: { x: 500, y: 500 }
})
await comfyPage.contextMenu.clickLitegraphMenuItem('LoadImage')
})
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
if (!graph || graph.links.size !== 1) return 'invalid link count'
if (graph.reroutes.size !== 1) return 'invalid reroutes count'
const linkId = graph.nodes.find((n) => n.title === 'Preview Image')
?.inputs[0].link
if (!linkId) return 'failed to resolve link id'
const rerouteId = graph.getLink(linkId)?.parentId
if (!rerouteId) return 'failed to resolve reroute id'
return !graph.reroutes.has(rerouteId) && 'reroute does not exist'
}),
'old link is disconnected, reroute is part of new connection'
)
.toBe(false)
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 136 KiB

View File

@@ -8,32 +8,25 @@ test('@vue-nodes In App Mode, widget width updates with panel size', async ({
comfyPage,
comfyMouse
}) => {
let legacyNodeId = toNodeId(10)
await test.step('setup', async () => {
const legacyNode = await comfyPage.nodeOps.addNode(
'DevToolsNodeWithLegacyWidget',
undefined,
{
x: 0,
y: 0
}
)
legacyNodeId = legacyNode.id
await comfyPage.appMode.enterAppModeWithInputs([
[String(legacyNodeId), 'legacy_widget']
])
await comfyPage.nodeOps.addNode('DevToolsNodeWithLegacyWidget', undefined, {
x: 0,
y: 0
})
await comfyPage.appMode.enterAppModeWithInputs([['10', 'legacy_widget']])
})
const getWidth = async () =>
(await comfyPage.appMode.linearWidgets.locator('canvas').boundingBox())
?.width ?? 0
const getWidth = () =>
comfyPage.page.evaluate(
(nodeId) => graph!.getNodeById(nodeId)!.widgets![0].width ?? 0,
toNodeId(10)
)
await test.step('Mouse clicks resolve to button regions', async () => {
const legacyWidget = comfyPage.appMode.linearWidgets.locator('canvas')
const { width, height } = (await legacyWidget.boundingBox())!
const nodeRef = await comfyPage.nodeOps.getNodeRefById(legacyNodeId)
const nodeRef = await comfyPage.nodeOps.getNodeRefById(10)
const legacyWidgetRef = await nodeRef.getWidget(0)
expect(await legacyWidgetRef.getValue()).toBe(0)
await legacyWidget.click({ position: { x: 20, y: height / 2 } })
@@ -43,8 +36,8 @@ test('@vue-nodes In App Mode, widget width updates with panel size', async ({
})
await test.step('Resize to update width', async () => {
await expect.poll(getWidth).toBeGreaterThan(0)
const initialWidth = await getWidth()
expect(initialWidth).toBeGreaterThan(0)
const gutter = comfyPage.page.getByRole('separator')

View File

@@ -3,43 +3,31 @@ import {
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { TestGraphAccess } from '@e2e/types/globals'
import { toNodeId } from '@/types/nodeId'
test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
test('Should display added widgets', async ({ comfyPage }) => {
const nodeId = toNodeId(
await comfyPage.page.evaluate(() => {
const node = window.app!.graph.nodes.find(
(node) => (node.widgets?.length ?? 0) === 1
)
if (!node) throw new Error('Node with one widget not found')
return String(node.id)
})
const loadCheckpointNode = comfyPage.page.locator(
'css=[data-testid="node-body-4"] > .lg-node-widgets > div'
)
const widgets = comfyPage.vueNodes
.getNodeLocator(nodeId)
.locator('.lg-node-widget')
await expect(widgets).toHaveCount(1)
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.graph.getNodeById(nodeId)
if (!node) throw new Error(`Node ${nodeId} not found`)
await expect(loadCheckpointNode).toHaveCount(1)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
node.addWidget('text', 'extra_widget_a', '', () => {})
}, nodeId)
await expect(widgets).toHaveCount(2)
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.graph.getNodeById(nodeId)
if (!node) throw new Error(`Node ${nodeId} not found`)
})
await expect(loadCheckpointNode).toHaveCount(2)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
node.addWidget('text', 'extra_widget_b', '', () => {})
}, nodeId)
await expect(widgets).toHaveCount(3)
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.graph.getNodeById(nodeId)
if (!node) throw new Error(`Node ${nodeId} not found`)
})
await expect(loadCheckpointNode).toHaveCount(3)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
node.addWidget('text', 'extra_widget_c', '', () => {})
}, nodeId)
await expect(widgets).toHaveCount(4)
})
await expect(loadCheckpointNode).toHaveCount(4)
})
test('Should hide removed widgets', async ({ comfyPage }) => {

View File

@@ -424,3 +424,35 @@ test.describe('Unserialized widgets', { tag: '@widget' }, () => {
.toBe(false)
})
})
test.describe('Widget value isolation', { tag: '@widget' }, () => {
test('re-added node does not inherit a cleared node widget value', async ({
comfyPage
}) => {
await comfyPage.nodeOps.clearGraph()
const firstNode = await comfyPage.nodeOps.addNode('EmptyLatentImage')
const firstWidth = await firstNode.getWidgetByName('width')
const defaultWidth = await firstWidth.getValue()
await comfyPage.page.evaluate(
({ id, name, value }) => {
const node = window.app!.graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found`)
const widget = node.widgets?.find((w) => w.name === name)
if (!widget) throw new Error(`Widget ${name} not found`)
widget.value = value
},
{ id: firstNode.id, name: 'width', value: 128 }
)
expect(await firstWidth.getValue()).toBe(128)
await comfyPage.nodeOps.clearGraph()
const secondNode = await comfyPage.nodeOps.addNode('EmptyLatentImage')
expect(secondNode.id).toBe(firstNode.id)
const secondWidth = await secondNode.getWidgetByName('width')
expect(await secondWidth.getValue()).toBe(defaultWidth)
})
})

View File

@@ -21,31 +21,6 @@ text below says "the World," read "the set of dedicated stores"; where it shows
`world.getComponent(id, Component)`, read the matching store getter (for
example `widgetValueStore.getWidget(widgetId)`).
### Amendment (2026-07-05, PRs 13436/13449)
Two stores joined the dedicated-store set: `linkStore` (link topology,
keyed by target input slot in root-graph-scoped buckets — see
[Link Topology Store](../architecture/link-topology-store.md)) and
`rerouteStore` (reroute chain state with link membership derived from
the links' `parentId` chains — see
[Reroute Chain Store](../architecture/reroute-chain-store.md)). Both
follow the proxy-returning registration pattern established by
`BaseWidget`/`widgetValueStore`: the store bucket is a `reactive(Map)`,
registration inserts the class's state object by reference and the class
adopts the reactive proxy read back from the bucket, so class field
writes are tracked without an action chokepoint. The `layoutStore` link
connectivity mirror and the `slot._floatingLinks` sets were deleted in
the same work; the layout store now holds geometry only.
### Amendment (2026-07-14, PR 13458)
`nodeBadgeStore` joined the dedicated-store set: plain `BadgeData` rows
keyed by `NodeId` in root-graph-scoped buckets. Unlike the
proxy-adoption stores above, rows are written by a reactive badge
system (`src/systems/badgeSystem.ts`) — a pure `computeBadges` inside a
per-node effect scope — see
[Node Badge Store](../architecture/node-badge-store.md).
## Context
The litegraph layer is built on deeply coupled OOP classes (`LGraphNode`, `LLink`, `Subgraph`, `BaseWidget`, `Reroute`, `LGraphGroup`, `SlotBase`). Each entity directly references its container and children — nodes hold widget arrays, widgets back-reference their node, links reference origin/target node IDs, subgraphs extend the graph class, and so on.
@@ -140,15 +115,6 @@ Components are plain data objects — no methods, no back-references to parent e
| `LinkVisual` | `color`, `path`, `_pos` (center point) |
| `LinkState` | `_dragging`, `data` |
> **Amended (2026-07-05):** `LinkEndpoints` shipped as
> `LinkTopology { id, originNodeId, originSlot, targetNodeId,
targetSlot, type, parentId? }` in a dedicated `linkStore`, keyed by
> **target input slot** (not link id) in root-graph-scoped buckets, with
> floating and subgraph-output links in an unkeyed side set. `LLink`
> reads through the store's reactive proxy (`_state`). See
> [Link Topology Store](../architecture/link-topology-store.md).
> `LinkVisual` and `LinkState` remain unextracted.
#### Subgraph (Node Components)
A node carrying a subgraph gains these additional components. Subgraphs are not a separate entity kind — see [Subgraph Boundaries](../architecture/subgraph-boundaries-and-promotion.md).
@@ -174,13 +140,6 @@ A node carrying a subgraph gains these additional components. Subgraphs are not
| `SlotConnection` | `link` (input) or `links[]` (output), `widget` locator |
| `SlotVisual` | `pos`, `boundingRect`, `color_on`, `color_off`, `shape` |
> **Amended (2026-07-05):** the input side of `SlotConnection` is
> subsumed by the `linkStore` key — the input-slot→link mapping _is_ the
> store's primary index (`isInputSlotConnected` / `getInputSlotLink`).
> The `slot._floatingLinks` sets were deleted; floating-link attachment
> is derived from the links' own endpoints (`slotFloatingLinks`). The
> `input.link` / `output.links` class mirrors remain un-migrated.
#### Reroute
| Component | Data (from `Reroute`) |
@@ -189,13 +148,6 @@ A node carrying a subgraph gains these additional components. Subgraphs are not
| `RerouteLinks` | `parentId`, input/output link IDs |
| `RerouteVisual` | `color`, badge config |
> **Amended (2026-07-04):** `RerouteLinks` was superseded during design
> review. The stored component is chain state only —
> `RerouteChain { parentId, floating? }` — and link membership
> (`linkIds` / `floatingLinkIds`) is derived from the links' `parentId`
> chains rather than stored. See
> [Reroute Chain Store](../architecture/reroute-chain-store.md).
#### Group
| Component | Data (from `LGraphGroup`) |
@@ -319,9 +271,6 @@ Companion architecture documents that expand on the design in this ADR:
| [ECS Migration Plan](../architecture/ecs-migration-plan.md) | Phased migration roadmap with shipping milestones and go/no-go criteria |
| [ECS Lifecycle Scenarios](../architecture/ecs-lifecycle-scenarios.md) | Before/after walkthroughs of lifecycle operations (node removal, link creation, etc.) |
| [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) | Design rationale for modeling subgraphs as node components, not separate entities |
| [Link Topology Store](../architecture/link-topology-store.md) | Design record for the `linkStore` — target-input-slot keying, root-scoped buckets, registration protocol |
| [Reroute Chain Store](../architecture/reroute-chain-store.md) | Design record for the `rerouteStore` — chain state, derived link membership, load-time id dedup |
| [Domain Glossary](../architecture/domain-glossary.md) | Canonical vocabulary for links, reroutes, chains, and membership |
| [ADR 0009: Subgraph promoted widgets](0009-subgraph-promoted-widgets-use-linked-inputs.md) | Follow-up decision for promoted widget identity and value ownership at subgraph boundaries |
| [Appendix: Critical Analysis](../architecture/appendix-critical-analysis.md) | Independent verification of the accuracy of the architecture documents |
| [Appendix: ECS Pattern Survey](../architecture/appendix-ecs-pattern-survey.md) | Survey of bitECS, miniplex, koota, ECSY, Thyseus, and Bevy — patterns adopted, departed, when to revisit |

View File

@@ -1,50 +0,0 @@
# Domain Glossary
Canonical vocabulary for the graph domain. Terms are added as they are
resolved during design work; keep entries implementation-free. Intended
to grow into a proper reference document.
Design records that rely on this vocabulary:
[Link Topology Store](link-topology-store.md),
[Reroute Chain Store](reroute-chain-store.md),
[Node Badge Store](node-badge-store.md),
[ADR 0008](../adr/0008-entity-component-system.md).
## Badges
- **Badge** — a small visual annotation rendered on a node: its numeric
id, lifecycle state, source pack, execution price, or an
extension-provided marker. Badges are presentation state; they never
affect execution and are never persisted with the workflow.
- **Badge kind** — the category a badge belongs to: **core** (identity /
lifecycle / source, projected from the node's definition and user
settings), **credits** (price of executing an API node, including
aggregated prices of nodes inside a subgraph), or **extension**
(provided by third-party code).
- **Badge source** — the domain state a badge's content is computed
from (settings, node definition, palette, pricing, widget values,
input connectivity). A badge is always a projection of its sources;
it is never authored directly by a user.
## Links & Reroutes
- **Link** — a directed data connection from one node's output slot to
another node's input slot. At most one live link targets a given input
slot.
- **Floating link** — a link with exactly one attached endpoint, kept
alive so a reroute chain survives disconnection. The unattached end is
unassigned.
- **Reroute** — a visual waypoint that a link's rendered path travels
through. Purely organisational; never affects data flow. A reroute's
identity is unique within a workflow, subgraphs included.
- **Reroute chain** — the ordered sequence of reroutes a link passes
through, from the node output toward the input. Each reroute names its
upstream neighbour via _parent_; the link names the chain's most
downstream reroute (the **terminal reroute**).
- **Link membership (of a reroute)** — the set of links whose chains pass
through that reroute. Membership is _defined by_ the chains: a link is a
member of exactly the reroutes on the chain walked from its terminal
reroute upstream. It is never authored independently of the chain.
- **Floating slot marker** — the annotation on the last reroute of a
floating chain recording which side (input or output) the chain still
faces.

View File

@@ -2,7 +2,7 @@
This document walks through the major entity lifecycle operations — showing the current imperative implementation and how each transforms under the ECS architecture from [ADR 0008](../adr/0008-entity-component-system.md).
ECS principles are realized across a set of dedicated Pinia stores keyed by string IDs (shipped in PR 12617): `widgetValueStore` (keyed by `WidgetId` = `graphId:nodeId:name`, see `src/types/widgetId.ts`), `layoutStore` (mutated via `useLayoutMutations()`), `nodeOutputStore`, `domWidgetStore`, `subgraphNavigationStore`, and `previewExposureStore`. Link topology and reroute chain state shipped later into `linkStore` (PR 13436, keyed by target input slot in root-graph-scoped buckets — see [link-topology-store.md](link-topology-store.md)) and `rerouteStore` (PR 13449, membership derived from the links' `parentId` chains — see [reroute-chain-store.md](reroute-chain-store.md)); `layoutStore` keeps link/reroute geometry only. Components live as plain-data entries in these stores; systems read and mutate them through store getters and command-style mutations.
ECS principles are realized across a set of dedicated Pinia stores keyed by string IDs (shipped in PR 12617): `widgetValueStore` (keyed by `WidgetId` = `graphId:nodeId:name`, see `src/types/widgetId.ts`), `layoutStore` (mutated via `useLayoutMutations()`), `nodeOutputStore`, `domWidgetStore`, `subgraphNavigationStore`, and `previewExposureStore`. Components live as plain-data entries in these stores; systems read and mutate them through store getters and command-style mutations.
Each scenario follows the same structure: **Current Flow** (what happens today), **ECS Flow** (the store-backed target), and a **Key Differences** table.
@@ -66,7 +66,6 @@ sequenceDiagram
participant Caller
participant CS as ConnectivitySystem
participant LM as useLayoutMutations()
participant LKS as linkStore
participant LS as layoutStore
participant WVS as widgetValueStore
participant NOS as nodeOutputStore
@@ -74,14 +73,12 @@ sequenceDiagram
Caller->>CS: removeNode(nodeId)
CS->>LKS: read node links (incoming + outgoing)
LKS-->>CS: link topologies
CS->>LS: read node links (incoming + outgoing)
LS-->>CS: linkIds
loop each link
CS->>LKS: unregister link topology
Note over CS,LKS: via the LGraph._removeLink chokepoint —<br/>map delete + store unregistration
CS->>LS: drop link geometry
Note over LKS,LS: linkStore owns topology;<br/>layoutStore only drops geometry
loop each linkId
CS->>LM: deleteLink(linkId)
Note over LM,LS: removes link entry +<br/>updates both slot endpoints
end
loop each widget on node
@@ -96,15 +93,15 @@ sequenceDiagram
### Key Differences
| Aspect | Current | ECS |
| ------------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| Lines of code | ~107 in one method | ~30 in system function |
| Entity types known | Graph knows about all 6+ types | ConnectivitySystem coordinates linkStore + layout/widget/output stores |
| Cleanup | Manual per-slot, per-link, per-reroute | linkStore unregistration per link (via `_removeLink`) + geometry drop per layout entry |
| Canvas notification | `setDirtyCanvas()` called explicitly | Vue reactivity: components re-render when store entries change |
| Store cleanup | WidgetValueStore not cleaned up; link geometry still removed from LayoutStore | Coordinated: `deleteWidget`, linkStore unregister + `deleteNode`, `removeNodeOutputs`, `unregisterWidget` |
| Undo/redo | `beforeChange()`/`afterChange()` manually placed | Layout mutations are command records, replayable and undoable |
| Testability | Needs full LGraph + LGraphCanvas | Needs only the relevant stores + ConnectivitySystem |
| Aspect | Current | ECS |
| ------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------- |
| Lines of code | ~107 in one method | ~30 in system function |
| Entity types known | Graph knows about all 6+ types | ConnectivitySystem coordinates layoutStore + widget/output stores |
| Cleanup | Manual per-slot, per-link, per-reroute | `deleteLink()`/`deleteNode()` mutations per layout entry |
| Canvas notification | `setDirtyCanvas()` called explicitly | Vue reactivity: components re-render when store entries change |
| Store cleanup | WidgetValueStore/LayoutStore NOT cleaned up | Coordinated: `deleteWidget`, `deleteLink`/`deleteNode`, `removeNodeOutputs`, `unregisterWidget` |
| Undo/redo | `beforeChange()`/`afterChange()` manually placed | Layout mutations are command records, replayable and undoable |
| Testability | Needs full LGraph + LGraphCanvas | Needs only the relevant stores + ConnectivitySystem |
## 2. Serialization
@@ -226,7 +223,7 @@ sequenceDiagram
end
opt has subgraph definitions
G->>G: deduplicateSubgraphNodeIds() + deduplicateSubgraphRerouteIds()
G->>G: deduplicateSubgraphNodeIds()
loop each subgraph (topological order)
G->>G: createSubgraph(data)
end
@@ -253,7 +250,7 @@ sequenceDiagram
end
G->>G: add floating links
G->>G: prune reroutes with derived totalLinks === 0
G->>G: validate reroutes
G->>G: _removeDuplicateLinks()
loop each serialized group
@@ -265,8 +262,6 @@ sequenceDiagram
Problems: two-phase creation is necessary because nodes need to reference each other's links during configure. Widget value restoration happens deep inside `node.configure()`. Store population is a side effect of configuration. Subgraph creation requires topological sorting to handle nested subgraphs.
Note on load-time id hygiene: root `configure()` deduplicates **node ids** and **reroute ids** across sibling subgraph definitions before configuring them (`deduplicateSubgraphNodeIds` / `deduplicateSubgraphRerouteIds`), because both share root-graph-scoped store buckets. Link-id dedup is deliberately absent — the linkStore key is the target input slot, not the link id, so duplicate link ids across definitions cannot collide. Orphaned reroutes are pruned by the derived `totalLinks === 0` check; the old `validateLinks` set-repair is gone (membership is derived, so there is no stored set to drift).
### ECS Flow
```mermaid
@@ -537,7 +532,7 @@ sequenceDiagram
participant G as LGraph
participant L as LLink
participant R as Reroute
participant LKS as linkStore
participant LS as LayoutStore
Caller->>N1: connectSlots(output, targetNode, input)
@@ -550,24 +545,24 @@ sequenceDiagram
end
N1->>L: new LLink(++lastLinkId, type, ...)
N1->>G: graph._addLink(link)
G->>LKS: register topology (keyed by target input slot)
Note over G,LKS: chokepoint: _links.set + linkStore registration
N1->>G: _links.set(link.id, link)
N1->>LS: layoutMutations.createLink()
N1->>N1: output.links.push(link.id)
N1->>N2: input.link = link.id
N1->>R: anchorRerouteChain(graph, link)
Note over N1,R: clears floating markers on the chain —<br/>membership is derived from link.parentId,<br/>no per-reroute linkIds writes
loop each reroute in path
N1->>R: reroute.linkIds.add(link.id)
end
N1->>G: incrementVersion()
N1->>G: _version++
N1->>N1: onConnectionsChange?(OUTPUT, ...)
N1->>N2: onConnectionsChange?(INPUT, ...)
N1->>G: setDirtyCanvas()
N1->>G: afterChange()
```
Problems: the source node orchestrates everything — it reaches into the graph's link map (via the `_addLink` chokepoint), the target node's slot, and the version counter. Reroute membership no longer needs writes (derived via rerouteStore), but the slot mirrors (`output.links`, `input.link`) are still mutated by hand.
Problems: the source node orchestrates everything — it reaches into the graph's link map, the target node's slot, the layout store, the reroute chain, and the version counter. 19 steps in one method.
### ECS Flow
@@ -575,28 +570,29 @@ Problems: the source node orchestrates everything — it reaches into the graph'
sequenceDiagram
participant Caller
participant CS as ConnectivitySystem
participant LKS as linkStore
participant LS as layoutStore
participant LM as useLayoutMutations()
Caller->>CS: connect(outputSlot, inputSlot)
CS->>LKS: getInputSlotLink(graphId, targetNodeId, targetSlot)
CS->>LS: read input slot link
opt already connected
CS->>LKS: unregister existing topology
CS->>LM: deleteLink(existingLinkId)
end
CS->>LKS: register LinkTopology {<br/> id, originNodeId, originSlot,<br/> targetNodeId, targetSlot, type<br/>}
Note over CS,LKS: the target-slot key IS the input-side<br/>slot connection — no separate endpoint update.<br/>Reroute membership derives from parentId;<br/>rerouteStore needs no write
CS->>LM: createLink(linkId, {<br/> originNodeId, originSlotIndex,<br/> targetNodeId, targetSlotIndex, type<br/>})
Note over LM,LS: createLink updates both slot endpoints<br/>and emits a command record
```
### Key Differences
| Aspect | Current | ECS |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| Orchestrator | Source node (reaches into graph, target slots) | ConnectivitySystem (reads linkStore) |
| Side effects | `incrementVersion()`, `setDirtyCanvas()`, `afterChange()`, callbacks | topology registration — endpoints + change tracking included |
| Reroute handling | None needed — membership derived from `parentId` chains (rerouteStore); `anchorRerouteChain` only clears floating markers | Same — derived membership is already the target shape |
| Slot mutation | Direct: `output.links.push()`, `input.link = id` | Input side subsumed by the linkStore key; output side pending SlotConnection extraction |
| Validation | `onConnectInput`/`onConnectOutput` callbacks on nodes | Validation system or guard function |
| Aspect | Current | ECS |
| ---------------- | ------------------------------------------------------------ | ------------------------------------------------------------- |
| Orchestrator | Source node (reaches into graph, target, reroutes) | ConnectivitySystem (reads layoutStore) |
| Side effects | `_version++`, `setDirtyCanvas()`, `afterChange()`, callbacks | `createLink()` command — endpoints + change tracking included |
| Reroute handling | Manual: iterate chain, add linkId to each | Reroute entries updated via layout mutations |
| Slot mutation | Direct: `output.links.push()`, `input.link = id` | `createLink(linkId, ...)` updates both endpoints |
| Validation | `onConnectInput`/`onConnectOutput` callbacks on nodes | Validation system or guard function |
## 7. Copy / Paste

View File

@@ -12,8 +12,7 @@ For verified accuracy of these documents, see
> **Target end-state (revised):** N dedicated Pinia stores keyed by composite
> string IDs, one store per concern (widget values, DOM widgets, layout, node
> outputs, subgraph navigation, preview exposure, link topology, reroute
> chains). The earlier "single unified
> outputs, subgraph navigation, preview exposure). The earlier "single unified
> World with branded numeric entity IDs and `getComponent`/`setComponent`" model
> was rejected. PR 12617 shipped the first stores against composite
> `graphId:nodeId:name` string keys (`WidgetId`). Phases below are reframed
@@ -114,26 +113,14 @@ narrow accessor surface. There is no single container that fronts all entities.
Shipped stores:
| Store | File |
| ---------------------------- | ----------------------------------------------- |
| `widgetValueStore` | `src/stores/widgetValueStore.ts` |
| `domWidgetStore` | `src/stores/domWidgetStore.ts` |
| `layoutStore` | `src/renderer/core/layout/store/layoutStore.ts` |
| `nodeOutputStore` | `src/stores/nodeOutputStore.ts` |
| `subgraphNavigationStore` | `src/stores/subgraphNavigationStore.ts` |
| `previewExposureStore` | `src/stores/previewExposureStore.ts` |
| `linkStore` ✅ PR 13436 | `src/stores/linkStore.ts` |
| `rerouteStore` ✅ PR 13449 | `src/stores/rerouteStore.ts` |
| `nodeBadgeStore` ✅ PR 13458 | `src/stores/nodeBadgeStore.ts` |
`linkStore` holds `LinkTopology` records (`src/types/linkTopology.ts`) keyed by
target input slot (`` `${targetNodeId}:${targetSlot}` ``) in root-graph-scoped
buckets — subgraphs share their root's bucket; floating links and links
targeting subgraph outputs live in a per-graph unkeyed side set. `rerouteStore`
holds `RerouteChain` records keyed by `RerouteId` in root-graph-scoped buckets;
link membership is not stored but derived from the links' `parentId` chains.
Design records: [Link Topology Store](link-topology-store.md),
[Reroute Chain Store](reroute-chain-store.md).
| Store | File |
| ------------------------- | ----------------------------------------------- |
| `widgetValueStore` | `src/stores/widgetValueStore.ts` |
| `domWidgetStore` | `src/stores/domWidgetStore.ts` |
| `layoutStore` | `src/renderer/core/layout/store/layoutStore.ts` |
| `nodeOutputStore` | `src/stores/nodeOutputStore.ts` |
| `subgraphNavigationStore` | `src/stores/subgraphNavigationStore.ts` |
| `previewExposureStore` | `src/stores/previewExposureStore.ts` |
`widgetValueStore` exposes `registerWidget`, `getWidget`, `setValue`,
`deleteWidget`, `getNodeWidgets`, and `clearGraph`, all `WidgetId`-native. There
@@ -273,14 +260,6 @@ link-endpoint records from the relevant stores:
Does not perform mutations yet — just queries. Validates that store connectivity
data is complete and consistent with the class-based graph.
> **Status (2026-07-05):** The reroute-membership query shipped as `linkStore` +
> `rerouteStore` (PRs 13436, 13449): "what links pass through this reroute" is
> derived per root graph by a cached reverse index over the links' `parentId`
> chains, and input-side connectivity is one lookup via
> `linkStore.isInputSlotConnected()` / `getInputSlotLink()`. Remaining:
> slot mirrors (`input.link` / `output.links`), output-side queries, and
> execution order.
**Risk:** Low. Read-only system with equivalence tests.
---
@@ -332,12 +311,6 @@ the system knowing about the callback API.
- Bridge lifecycle events remain internal. Legacy callbacks stay the public
compatibility API during Phase 4.
> **Status (2026-07-05):** Link and reroute store registration now funnels
> through canonical `LGraph` mutation chokepoints: `_addLink`/`_removeLink` and
> `_addReroute`/`_removeReroute` pair every map mutation with store
> (un)registration, and `clear()` / subgraph-definition GC unregister whole
> graphs. The callback contract above and slot-mirror extraction remain.
**Risk:** High. Extensions depend on callback ordering and timing. Must be
validated against real-world extensions.
@@ -623,16 +596,13 @@ state between these calls.
The dedicated stores use per-concern keying strategies:
| Store | Key Format |
| ------------------------- | ------------------------------------------------------------------------------------ |
| `widgetValueStore` | `WidgetId` (`graphId:nodeId:name`) |
| `domWidgetStore` | Widget UUID |
| `layoutStore` | Raw nodeId/linkId/rerouteId |
| `nodeOutputStore` | `"${subgraphId}:${nodeId}"` |
| `subgraphNavigationStore` | subgraphId or `'root'` |
| `linkStore` | `` `${targetNodeId}:${targetSlot}` `` (target input slot), root-graph-scoped buckets |
| `rerouteStore` | `RerouteId`, root-graph-scoped buckets |
| `nodeBadgeStore` | `NodeId`, root-graph-scoped buckets |
| Store | Key Format |
| ------------------------- | ---------------------------------- |
| `widgetValueStore` | `WidgetId` (`graphId:nodeId:name`) |
| `domWidgetStore` | Widget UUID |
| `layoutStore` | Raw nodeId/linkId/rerouteId |
| `nodeOutputStore` | `"${subgraphId}:${nodeId}"` |
| `subgraphNavigationStore` | subgraphId or `'root'` |
ADR 0009 refines the promoted-widget target: promoted value widgets should use
host boundary identity (`host node locator + SubgraphInput.name`), not interior
@@ -659,8 +629,7 @@ Phase 0c (doc fixes) ─────────┤── no dependencies betwe
Phase 1a (branded WidgetId) ── ✅ shipped (PR 12617)
Phase 1b (store state shapes) ─┐── depends on 1a
Phase 1c (dedicated stores) ──┘── widgetValueStore + 7 others shipped
(PR 12617; linkStore PR 13436; rerouteStore PR 13449)
Phase 1c (dedicated stores) ──┘── widgetValueStore + 5 others shipped (PR 12617)
Phase 2a (Position via layoutStore) ─┐── depends on 1c
Phase 2b (Widget consolidation) ────┤── ✅ largely shipped; depends on 1a, 1c

View File

@@ -18,12 +18,7 @@ Map&lt;WidgetId, WidgetValue&gt;"]
DomWidgetStore["domWidgetStore
Map&lt;WidgetId, DomWidgetState&gt;"]
LayoutStore["layoutStore (Y.js CRDT)
nodeId / linkId / rerouteId → geometry"]
LinkStore["linkStore
rootGraphId → targetNodeId:targetSlot
→ LinkTopology"]
RerouteStore["rerouteStore
rootGraphId → RerouteId → RerouteChain"]
nodeId / linkId / rerouteId → layout"]
NodeOutputStore["nodeOutputStore
Map&lt;nodeLocatorId, outputs&gt;"]
SubgraphNavStore["subgraphNavigationStore
@@ -44,8 +39,7 @@ preview exposure state"]
RS -->|reads| Stores
SS -->|reads/writes| Stores
CS -->|reads/writes| LinkStore
CS -->|reads/writes| RerouteStore
CS -->|reads/writes| LayoutStore
LS -->|reads/writes| LayoutStore
ES -->|reads| NodeOutputStore
VS -->|reads/writes| LayoutStore
@@ -69,31 +63,20 @@ subgraphId:nodeId"]
NID["nodeId (raw)"]
LID["linkId (raw)"]
RID["rerouteId (raw)"]
TIS["targetNodeId:targetSlot
(root-graph-scoped bucket)"]
end
WID -->|widgetValueStore, domWidgetStore| W["keyed lookups"]
NLID -->|nodeOutputStore| W
NID -->|layoutStore| W
LID -->|layoutStore| W
RID -->|layoutStore, rerouteStore| W
TIS -->|linkStore| W
RID -->|layoutStore| W
```
`WidgetId = graphId:nodeId:name` is itself a branded string (see
`src/types/widgetId.ts`). `nodeLocatorId = subgraphId:nodeId` addresses node
outputs. `layoutStore` keys geometry records by raw `nodeId` / `linkId` /
`rerouteId`. `linkStore` keys `LinkTopology` by **target input slot**
(`targetNodeId:targetSlot`) inside root-graph-scoped buckets — the link id is
NOT the key; at most one live link can target an input slot, so the target is
the natural primary key (see
[link-topology-store.md](link-topology-store.md)). Links without a unique
target (floating links, `SUBGRAPH_OUTPUT_ID` targets) live in a per-graph
unkeyed side set. `rerouteStore` keys `RerouteChain` by raw `rerouteId` in
root-graph-scoped buckets (see
[reroute-chain-store.md](reroute-chain-store.md)). Each store enforces its own
key shape; there is no single shared entity-ID type across stores.
outputs. `layoutStore` keys layout records by raw `nodeId` / `linkId` /
`rerouteId`. Each store enforces its own key shape; there is no single shared
entity-ID type across stores.
Note: `graphId` is a scope identifier. It identifies which graph an entity
belongs to and forms the prefix of `WidgetId`. Subgraphs are nodes with a
@@ -194,15 +177,14 @@ target_id, target_slot, type"]
B5["resolve()"]
end
subgraph After["target-slot-keyed topology (linkStore) + unextracted state"]
subgraph After["linkId-keyed components (layoutStore)"]
direction TB
A1["LinkTopology — SHIPPED
{ id, originNodeId, originSlot,
targetNodeId, targetSlot, type, parentId? }
keyed by targetNodeId:targetSlot"]
A2["LinkVisual — not yet extracted
A1["LinkEndpoints
{ originId, originSlot,
targetId, targetSlot, type }"]
A2["LinkVisual
{ color, path, centerPos }"]
A3["LinkState — not yet extracted
A3["LinkState
{ dragging, data }"]
end
@@ -216,26 +198,6 @@ keyed by targetNodeId:targetSlot"]
style After fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
```
`LinkTopology` has shipped in `src/stores/linkStore.ts`: `LLink._state` IS the
store entry — the class fields are accessors over the store's reactive proxy,
so the store and the instance cannot disagree. Registration is first-wins with
identity-checked delete/update. See
[link-topology-store.md](link-topology-store.md) for the full design record.
`LinkVisual` and `LinkState` remain on the `LLink` class.
### Reroute: RerouteChain (shipped)
Reroutes follow the same pattern. `RerouteChain { id, parentId?, floating? }`
lives in `src/stores/rerouteStore.ts`, keyed by `RerouteId` in
root-graph-scoped buckets; `Reroute._chain` is the store entry. Link
membership (`Reroute.linkIds` / `floatingLinkIds`) is **not stored** — it is
derived per root graph by a cached computed reverse index walking the links'
`parentId` chains, replacing ~10 hand-maintained write sites and the
`validateLinks` set-repair. See
[reroute-chain-store.md](reroute-chain-store.md). Reroute _position_ is not
yet migrated: `Reroute.posInternal` remains the source of truth, with the
layout store holding a partial `{ id, position }` mirror.
### Widget: Before vs After
```mermaid
@@ -286,8 +248,8 @@ graph TD
direction TB
CS["ConnectivitySystem
Manages link/slot mutations.
Writes: LinkTopology (shipped),
SlotConnection (future), Connectivity"]
Writes: LinkEndpoints, SlotConnection,
Connectivity"]
VS["VersionSystem
Centralizes change tracking.
Replaces 15+ scattered _version++.
@@ -350,11 +312,9 @@ graph LR
Exe["Execution"]
Props["Properties"]
WC["WidgetContainer"]
LE["LinkTopology
(linkStore — shipped)"]
LE["LinkEndpoints"]
LV["LinkVisual"]
SC["SlotConnection
(output side — future)"]
SC["SlotConnection"]
SV["SlotVisual"]
WVal["WidgetValue"]
WL["WidgetLayout"]
@@ -373,7 +333,7 @@ graph LR
LS -.->|read| WC
CS -->|write| LE
CS -.->|future write| SC
CS -->|write| SC
CS -->|write| Con
ES -.->|read| Con
@@ -389,14 +349,6 @@ graph LR
VS -.->|read| Con
```
ConnectivitySystem's `LinkEndpoints` write target is realized as
`LinkTopology` in `linkStore`. The input side of `SlotConnection`
(`input.link`) is subsumed by the linkStore key itself — "which link targets
this input slot" is the store's primary index
(`isInputSlotConnected` / `getInputSlotLink`) — though the `input.link` slot
mirror still exists on the class. The output side (`output.links[]`) remains
future extraction work.
## 4. Dependency Flow
### Before: Tangled References

View File

@@ -66,7 +66,7 @@ graph TD
Link -.->|"origin_id, target_id"| Node
Link -.->|"parentId"| Reroute
Slot -.->|"link / links[]"| Link
Reroute -.->|"linkIds (derived, rerouteStore)"| Link
Reroute -.->|"linkIds"| Link
Reroute -.->|"parentId"| Reroute
Group -.->|"_children Set"| Node
Group -.->|"_children Set"| Reroute
@@ -103,14 +103,10 @@ type: ISlotType"]
Link -.->|"parentId"| R1["Reroute A"]
R1 -.->|"parentId"| R2["Reroute B"]
R1 -.-|"linkIds (derived)"| Link
R2 -.-|"linkIds (derived)"| Link
R1 -.-|"linkIds Set"| Link
R2 -.-|"linkIds Set"| Link
```
`Reroute.linkIds` / `floatingLinkIds` are read-only accessors derived from the
links' own `parentId` chains by `rerouteStore` — membership is never stored
(see [reroute-chain-store.md](reroute-chain-store.md)).
### Subgraph Boundary Connections
```mermaid
@@ -146,20 +142,14 @@ graph TD
```mermaid
graph LR
Slot["Source Slot"] -->|"drag starts"| FL["Floating LLink
origin or target = UNASSIGNED_NODE_ID"]
origin_id=-1 or target_id=-1"]
FL -->|"stored in"| FLMap["graph.floatingLinks Map"]
FL -->|"registered in"| SideSet["linkStore unkeyed side set
(no unique target slot)"]
FL -.->|"may pass through"| Reroute
Reroute -.-|"floatingLinkIds (derived)"| FL
Reroute -.-|"floatingLinkIds Set"| FL
FL -->|"on drop"| Permanent["Permanent LLink
(graph._links + linkStore target index)"]
(registered in graph._links)"]
```
A floating link's slot attachment is fully encoded in its own endpoints —
slots hold no floating-link sets (`slotFloatingLinks()` in `LLink.ts` derives
attachment by scanning `graph.floatingLinks`).
## 3. Rendering
How LGraphCanvas draws each entity type.
@@ -264,7 +254,7 @@ stateDiagram-v2
```mermaid
stateDiagram-v2
[*] --> Created: node.connect() or connectSlots()
Created --> Registered: graph._addLink(link)
Created --> Registered: graph._links.set(id, link)
state Registered {
[*] --> Active
@@ -278,18 +268,12 @@ stateDiagram-v2
note right of Created
new LLink(id, type, origin, slot, target, slot)
_addLink sets graph._links entry and
registers topology in linkStore
(keyed by target input slot).
Output slot.links[] updated.
Input slot.link set.
end note
note right of Removed
Removed from graph._links and
unregistered from linkStore
(via _removeLink or link.disconnect).
Link geometry dropped from layoutStore.
Removed from graph._links.
Orphaned reroutes cleaned up.
graph._version incremented.
end note
@@ -378,10 +362,6 @@ graph TD
WVS["WidgetValueStore
(Pinia)"]
PES["PreviewExposureStore
(Pinia)"]
LKS["LinkStore
(Pinia)"]
RRS["RerouteStore
(Pinia)"]
LM["LayoutMutations
(composable)"]
@@ -403,20 +383,10 @@ lastRerouteId, lastGroupId)"]
SGNode -->|"host-scoped preview exposures"| PES
PES -.->|"keyed by host node locator"| SGNode
%% LinkStore
Graph -->|"_addLink()/_removeLink() register/unregister"| LKS
Link <-->|"_state IS the store entry (endpoint accessors)"| LKS
LKS -.->|"keyed by rootGraphId + targetNodeId:targetSlot"| Link
%% RerouteStore
Graph -->|"_addReroute()/_removeReroute()"| RRS
Reroute <-->|"_chain (parentId, floating)"| RRS
RRS -.->|"derived linkIds membership"| Reroute
%% LayoutMutations (geometry only)
%% LayoutMutations
Node -->|"pos/size setter"| LM
Reroute -->|"move()"| LM
Link -->|"disconnect(): drop link geometry"| LM
Link -->|"connectSlots()/disconnect()"| LM
Graph -->|"add()/remove()"| LM
%% Graph state

View File

@@ -168,24 +168,12 @@ No central mechanism exists. It's easy to forget an increment (stale render) or
Domain objects call Pinia composables at the module level or in methods, creating implicit dependencies on the Vue runtime:
- `Reroute.ts:31``const layoutMutations = useLayoutMutations()` (module scope)
- `LLink.ts:7`imports the `layoutStore` singleton at module scope; `useLinkStore()` is called inside methods and helpers (needs an active Pinia, but not eagerly at import time)
- `Reroute.ts``useRerouteStore()` called inside methods for derived membership
- `LLink.ts:24``const layoutMutations = useLayoutMutations()` (module scope)
- `Reroute.ts`same pattern at module scope
- `BaseWidget.ts` — imports `useWidgetValueStore`
These make the domain objects untestable without a Vue app context.
### Reroute Membership Dual-Writes (solved)
`Reroute.linkIds` / `floatingLinkIds` were hand-maintained `Set`s written from
~10 scattered call sites (connect, disconnect, paste, configure, subgraph
pack/unpack, ...), with `Reroute.validateLinks()` repairing the inevitable
drift on load. Membership is now derived from the links' own `parentId`
chains via `rerouteStore` — the accessors are read-only, the write sites and
`validateLinks` are deleted, and orphaned reroutes are pruned by the derived
`totalLinks === 0` instead of set-repair. See
[reroute-chain-store.md](reroute-chain-store.md) (Decision 1).
### Change Notification Sprawl
`beforeChange()` and `afterChange()` (undo/redo checkpoints) are called from

View File

@@ -1,112 +0,0 @@
# Link Topology Store
Date: 2026-07-05 (retroactive design record; implemented in PR #13436)
Status: Accepted
Design record for extracting link topology into a dedicated store per
[ADR 0008](../adr/0008-entity-component-system.md). Amends the
`LinkEndpoints` component described there. The
[Reroute Chain Store](reroute-chain-store.md) builds directly on this
store; shared vocabulary lives in the
[Domain Glossary](domain-glossary.md).
## Decision 1: One state object, class reads through it
`LLink` no longer owns copies of its topology fields. A single plain
object,
```
LinkTopology { id, originNodeId, originSlot, targetNodeId, targetSlot,
type, parentId? }
```
backs the link: `LLink._state` holds it, and `id`, `type`, `origin_id`,
`origin_slot`, `target_id`, `target_slot`, and `parentId` are accessors
over it. Registration inserts that same object into the store by
reference and re-assigns `_state` to the reactive proxy read back from
the bucket, so subsequent class writes are Vue-tracked (the `BaseWidget`
pattern — see Decision 4 of the reroute chain store record). There is no
store-side copy to drift from the class: the store entry _is_ the
class's state.
The store is runtime state only; `LLink.asSerialisable` reads the same
fields it always did, and serialization goldens (key order plus
byte-identical round-trips) pin the wire format.
## Decision 2: Keyed by target input slot, not link id
The primary index is keyed by `` `${targetNodeId}:${targetSlot}` ``.
Two facts make this the right key:
- **The domain invariant**: at most one live link targets a given input
slot. The key is unique by construction for live links.
- **The dominant query**: consumers ask "is this input slot connected,
and by what?" (`isInputSlotConnected`, `getInputSlotLink`). The key
answers it in one lookup with no scan.
Link _ids_ are only unique per owning graph, not per root graph, so an
id-keyed root bucket needed a load-time link-id dedup pass and a
first-wins registration protocol to survive collisions across sibling
subgraph definitions. Re-keying by target slot deleted both: colliding
link ids never touch the index, so workflows load without link-id
rewrites.
Rejected: keeping the id key plus dedup/first-wins. That machinery
existed only to compensate for a key the queries never used.
## Decision 3: Root-graph-scoped buckets, unkeyed side set
Buckets are scoped by `rootGraph.id` — subgraphs share their root's
bucket — matching `widgetValueStore` and the later `rerouteStore`.
Re-keying entries to their owning graph was evaluated and rejected: it
reintroduces per-graph lifecycle bookkeeping the root scope avoids, and
no query wants owning-graph granularity that `graphTopologies` filtering
doesn't already provide.
Links without a unique live target go in a per-graph side `Set` instead
of the primary index:
- **Floating links** — exactly one assigned endpoint; the other is
`UNASSIGNED_NODE_ID`. A floating link attached to an input slot does
not answer `isInputSlotConnected`, preserving `input.link` semantics.
- **Links targeting `SUBGRAPH_OUTPUT_ID`** — the id is a shared
constant, so `targetNodeId:targetSlot` is not unique across the
subgraphs sharing a root bucket.
## Decision 4: Registration protocol
- `registerLink` is **first-wins**: if a different topology already
holds the target key, the call returns `undefined` and the loser
stays detached. `link._graphId` records a won registration; it is the
ownership marker that lets `unregisterLink` and re-registration no-op
safely for losers.
- `deleteLink` and `updateEndpoint` are **identity-checked** (`toRaw`
comparison): only the registered topology can vacate or re-key its
slot.
- `updateEndpoint` re-keys atomically — displace, patch fields through
a reactive wrapper, re-place under the new key — and returns
`undefined` when the new target is already occupied. The `reactive()`
wrap stays even though registered links already hold the proxy: the
store is public API and may be handed a raw topology object.
## Decision 5: Mutation chokepoints
All `graph._links` map mutation funnels through `LGraph._addLink` /
`_removeLink`, which pair the map write with store
registration/unregistration (and link-layout cleanup on removal).
`addFloatingLink` / `removeFloatingLink` do the same for the floating
map. `LLink.disconnect` performs the equivalent effects inline because
it only holds a `LinkNetwork`, and unregisters before reroute pruning so
derived reroute counts exclude the dying link. `clear()` and
subgraph-definition GC unregister whole graphs
(`unregisterAllLinkTopologies` / `clearGraph`).
## Scope
This design covers link topology (endpoints, type, chain terminus).
Link visual state (`color`, path caches) and the layout store's link
_geometry_ records are out of scope. The `input.link` / `output.links`
slot mirrors remain the litegraph-native representation un-migrated
consumers read; extracting them is the `SlotConnection` component work
in the [ECS migration plan](ecs-migration-plan.md), not part of this
store.

View File

@@ -1,168 +0,0 @@
# Node Badge Store
Date: 2026-07-05 (updated 2026-07-06)
Status: Partially implemented — decisions 14 shipped as
`src/stores/nodeBadgeStore.ts` + `src/systems/badgeSystem.ts` (slice A);
decision 5 and the open decisions below await the consumer-cutover PR
(slice B). Follow-up to the
[link topology store](link-topology-store.md),
[reroute chain store](reroute-chain-store.md), and the
[node data store draft](node-data-store.md)
Design record for extracting node badges off `LGraphNode` instances into
a dedicated store per [ADR 0008](../adr/0008-entity-component-system.md),
going straight to plain-data components — no interim closure storage.
Vocabulary: [domain glossary § Badges](domain-glossary.md#badges).
## Current state (what this replaces)
`LGraphNode.badges: (LGraphBadge | (() => LGraphBadge))[]` mixes three
things: a **core badge closure** (id / lifecycle / source, re-derived
independently by the Vue renderer, which skips it positionally via
`slice(1)`), **credits badge closures** (`creditsBadgeGetter`,
`buildWrapperAwarePriceBadge` — hand-rolled reactive computeds the
legacy canvas polls per frame), and a public push surface for
extensions. Badge changes are announced by a single manual
`node:property:changed` trigger in `usePriceBadge`; plain pushes are
invisible. Credits badges are classified by icon identity
(`icon.image === componentIconSvg`). Badges never serialize (verified:
no `badges` key in `serialize()`/`configure`).
Every closure is a reactive computed feeding an unreactive array, and
the Vue renderer has already re-implemented all of their reactivity as
store-tracked dependencies. The design deletes the closures and makes
the store rows the single truth both renderers read.
## Decision 1: Plain `BadgeData` rows, no interim shape
The store holds only plain data (ADR 0008 component rule):
```
BadgeData {
kind: 'core' | 'credits' | 'extension'
text: string
fgColor?: string
bgColor?: string
iconKey?: string // resolved via a small icon registry ('credits' → SVG)
}
```
No `onClick` (no producer exists; `LGraphButton`/`title_buttons` are a
separate surface and out of scope). No `Image` objects — icons are
referenced by key. A `commandId` field can be added if a clickable badge
requirement ever materialises.
Rejected: an interim `BadgeEntry { kind, source: LGraphBadge | thunk }`
phase. The public element type of `node.badges` breaks either way; one
break that lands on the end-state shape beats two.
## Decision 2: Store shape and keying
`nodeBadgeStore`: root-graph-scoped buckets (`rootGraph.id`) keyed by
`NodeId`, each holding an ordered reactive `BadgeData[]`. Register /
unregister / unregister-all trio at the `LGraph.add` / `LGraph.remove` /
`clear()` chokepoints, identity-checked deletes — the shipped store
conventions.
Rows are partitioned by `kind` at read time; the positional `slice(1)`
convention and icon-identity credits detection are deleted. Display
order is kind order (core, credits, extension), not insertion order.
## Decision 3: A reactive BadgeSystem writes the rows
One system module owns the recomputation: per registered node, an
`effectScope` runs a pure `computeBadges(sources) → BadgeData[]`
function inside a thin watch shell and writes the node's rows. Sources
are the existing stores: `nodeDefStore`, `settingStore`,
`colorPaletteStore`, `useNodePricing` revision refs, `widgetValueStore`,
`linkStore` input connectivity. The pure function is the future
command-pipeline phase body (ADR 0003 systems ADR); only the scheduler
shell changes when that lands.
`useNodeBadge` / `usePriceBadge` stop pushing closures; their derivation
logic moves into `computeBadges`. The manual `'badges'`
`node:property:changed` trigger, the `badges` case in
`useGraphNodeManager`, and `VueNodeData.badges` are deleted.
`usePartitionedBadges` collapses to a store query partitioned by kind;
its manual dependency-touching (`trackNodePrice`,
`trackSubgraphInnerNodePrices`) moves inside the system.
## Decision 4: Core badges are system-written rows too
Core (#id / lifecycle / source) badges are materialized by the system
like every other kind, so both renderers consume one uniform row set.
This kills the current dual derivation (legacy closure + Vue-side
re-derivation). Materialized-by-system is the ECS write path, not a
mirror: no other component stores this projection.
## Decision 5: Legacy canvas consumes rows via a draw cache
`drawBadges` renders from `BadgeData`, constructing and caching
`LGraphBadge` draw objects keyed by row content (the `Reroute` id-badge
pattern, memoized). `_boundingRect` hit-test state stays renderer-side.
Frame-budget parity per ADR 0008's render mitigations applies.
## Implementation notes (slice A)
- Registration is bucket-key presence: the `LGraph.add`/`remove`/`clear`
chokepoints call the import-light store trio
(`registerNode`/`unregisterNode`/`clearGraph`), and the system watches
`registeredNodeIds` to attach/detach per-node effect scopes. Litegraph
never imports the system, so the pricing/nodeDef dependency graph
(which runtime-imports the litegraph barrel) stays acyclic. The system
takes a `resolveNode` seam and will be bootstrapped at the app layer
in slice B, when the `Comfy.NodeBadge` extension stops pushing
closures; until then rows are written only under test. Row writes are
refused for unregistered nodes so a late effect flush cannot
resurrect a bucket key the chokepoints deleted.
- Two write paths: `setBadgesOfKind` is the system's bulk
replace-one-kind recompute path (`@internal`); extension rows go
through `registerBadge`/`deleteBadge` per row, identity-checked, so
independent writers cannot stomp each other.
- The shell still reads `node.constructor.nodeData` and `node.inputs`
(untracked instance state) to map pricing input names to slot
indices — parity with the legacy closures. Those reads become store
lookups when slot data is store-backed (node data store draft).
- Core rows are fine-grained — one row per part in lifecycle, id, source
order — with one visibility rule (`badgeTextVisible`, the legacy
semantics: `HideBuiltIn` respected for every part). Joining,
bracket decoration, and truncation are renderer presentation and move
to the slice-B legacy draw cache. Rows store lifecycle text with the
`[]` brackets trimmed. Known unification effects: the Vue renderer
gains `HideBuiltIn` handling for id badges, and core nodes' `🦊`
source row becomes data the Vue partition may substitute with its
Comfy-logo chip.
- A credits row is emitted only when the display price label is
non-empty; async pricing fills it via the per-node revision ref.
- Subgraph credits aggregation is not yet in the system — it stays on
the `updateSubgraphCredits` closure path until open decision 3 below
is resolved.
## Open decisions (interview pending)
1. **Legacy `node.badges` surface** — recommended: a converting
accessor exposing the node's `BadgeData[]` proxy; bare
`LGraphBadge`/thunk pushes auto-convert (thunk evaluated once,
`Image` icons dropped) with a one-time deprecation warning pointing
at the store API. Alternatives: delete the property outright, or a
read-only view. Ecosystem scan found zero genuine third-party
writers; ADR 0008 extension-impact guidance applies regardless.
2. **`node.badgePosition`** — recommended: delete (single writer sets a
constant `TopRight` on every node; single reader; renderer policy,
not badge data). Deprecated accessor warns on write.
3. **Subgraph credits aggregation triggers** — recommended: the three
existing events (`litegraph:set-graph`, `subgraph-converted`,
`afterConfigureGraph`) bump a revision the system watches; swap to
reactive `SubgraphStructure` dependencies when that state is
store-backed.
## Scope and sequencing
Slices: (A) store + `BadgeData` + system for core and credits +
chokepoint registration; (B) consumer cutover — Vue partition query,
legacy draw cache, trigger/`VueNodeData.badges` deletions, legacy
surface shim; extension-facing deprecation notes. Independent of the
pending `nodeDataStore` extraction and lands before it, shrinking its
Decision 4/6 scope (one less `VueNodeData` field, one less property
handler). `PartnerNodesList`'s `find(isCreditsBadge)` migrates to a
store query by kind.

View File

@@ -1,145 +0,0 @@
# Node Data Store
Date: 2026-07-05
Status: Draft (design interview in progress; follow-up to the
[link topology store](link-topology-store.md) and
[reroute chain store](reroute-chain-store.md))
Design record for extracting the remaining Node-owned components into a
dedicated store per [ADR 0008](../adr/0008-entity-component-system.md),
eliminating the `VueNodeData` mirror and most of
`src/composables/graph/useGraphNodeManager.ts`.
## Decision 1: One store, one plain state object per node
`nodeDataStore` holds a single plain `NodeState` object per node,
registered by reference with proxy-returning registration (the
`BaseWidget` pattern, [reroute store Decision 4](reroute-chain-store.md)).
ADR 0008's Node component rows (`NodeVisual`, `Execution`, ...) become
field groupings inside `NodeState`, not separate records or stores.
Buckets are root-graph-scoped (`rootGraph.id`), keyed by `NodeId`.
Node-id uniqueness across sibling subgraph definitions is already
guaranteed by the load-time dedup pass
(`src/lib/litegraph/src/subgraph/subgraphDeduplication.ts`).
## Decision 2: Field set — what is NodeState, what is elsewhere
```
NodeState {
id: NodeId
graphId: UUID // owning (sub)graph — partitioning + locator ids
type: string // identity, with apiNode?: boolean
title: string
titleMode?: TitleMode
mode: number
flags: { collapsed?, pinned?, ghost? }
color?: string
bgcolor?: string
shape?: number
resizable?: boolean
showAdvanced?: boolean
}
```
Excluded — owned or derived elsewhere; referencing them here would be a
mirror (the hard constraint of this phase):
| Field | Owner |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `selected` | `canvasStore.selectedNodeIds` (already what `LGraphNode.vue` reads) |
| `executing` | `executionStore` via `useNodeExecutionState` (already what Vue reads) |
| `hasErrors` | derived from `executionErrorStore` / missing-model/media stores; `node.has_errors` stays a legacy-canvas class field written by `useNodeErrorFlagSync` |
| position / size / z | `layoutStore` |
| widget values / order | `widgetValueStore` |
| input link connectivity | `linkStore` (`getInputSlotLink` / `isInputSlotConnected`) |
| `badges` | `nodeBadgeStore` — plain `BadgeData` rows written by a badge system; see [Node Badge Store](node-badge-store.md) |
| `inputs` / `outputs` | deferred — see Decision 3 |
`VueNodeData.selected` and `.executing` are dead fields today (no
production consumer reads them); they are deleted, not migrated.
## Decision 3: Slot arrays deferred; `inputs[].link` readers migrate now
`NodeInputSlot` / `NodeOutputSlot` are class instances with methods —
Slot entity extraction (ADR 0008 `SlotIdentity` etc.) is its own future
phase. The slot arrays stay class-side, keeping the `shallowReactive`
graft for renderer reactivity.
What this phase does remove is the last `inputs[].link` dependency: the
three remaining readers (`nodeDataUtils.linkedWidgetedInputs` used by
`NodeSlots`, and `usePartitionedBadges`' badge computed plus its
exported `trackNodePrice`) move to `linkStore.isInputSlotConnected`
queries — presence is all any of them needed — which deletes the
`node:slot-links:changed``refreshNodeInputs` reprojection in
`useGraphNodeManager`, the dead `node:slot-errors:changed` handler
(zero emitters repo-wide), and the node-removal refresh-all loop.
With their last listeners gone, both trigger actions are deleted
outright — emitters, event-map entries, and types (the badge system
sources connectivity from `linkStore`, not events; resurrect from git
if a consumer ever materialises). Readers get the root graph id from
`canvasStore.rootGraphId`, the shared tracked accessor, rather than
per-site `canvas?.graph?.rootGraph.id` chains.
Shipped ahead of the store itself (2026-07-05).
## Decision 4: Renderer consumes the NodeState proxy, `VueNodeData` dies
`GraphCanvas` iterates the store's bucket for the active graph (filtered
by `NodeState.graphId`) and passes the reactive `NodeState` proxy down
the existing prop-drilling path (`LGraphNode``NodeHeader` /
`NodeSlots` / `NodeContent` / `NodeWidgets`). Children read proxy fields
directly; Vue tracks the store state, so the per-property
`node:property:changed` → snapshot-rewrite handlers in
`useGraphNodeManager` are deleted wholesale.
Slot arrays reach `NodeSlots` via the existing live-node access
(`getNodeByLocatorId`), not through `NodeState`.
`LGraphNodePreview` constructs a synthetic `NodeState` (as it does a
synthetic `VueNodeData` today). `AppModeWidgetList` stops calling
`extractVueNodeData` and reads the registered `NodeState` + live node.
## Decision 5: Registration lifecycle and class adoption
Follows the shipped trio convention (`LLink` / `Reroute`):
- `LGraphNode` constructs its `_state: NodeState` at instantiation;
`registerNodeState(graph, node)` inserts it by reference and the class
adopts the returned reactive proxy; `node._graphId` (root id) is the
registration-ownership marker.
- Chokepoints: `LGraph.add` / `LGraph.remove` (the canonical sites),
`unregisterAllNodeStates(graph)` on graph `clear()`, identity-checked
delete (`toRaw` compare) so only the registered state vacates its key.
- Class fields become accessors reading through `_state`.
`LGraphNodeProperties`' instrumented descriptors keep their
get/set + `node:property:changed` emission but store the value in
`_state` instead of a closure — trigger consumers (minimap,
`useErrorClearingHooks`) keep working unchanged.
- Serialization is unaffected: `serialize()` reads the same properties
through the accessors.
## Decision 6: What remains of useGraphNodeManager
Deleted: `extractVueNodeData`, the `vueNodeData` map, all
`node:property:changed` handlers, `syncWithGraph`, `getNode()`
(consumers use `graph.getNodeById`), the `node:slot-links:changed`
handler (Decision 3).
Remaining renderer-side lifecycle, slimmed into `useVueNodeLifecycle`
(or a small successor):
- layoutStore seeding on node add/remove (`createNode`/`deleteNode`
layout mutations, including the `onAfterGraphConfigured` deferral) —
layout is renderer policy, not entity data.
- `node:slot-label:changed` slot-array reprojection — dies with the
Slot extraction phase.
## Scope
Covers node shell state, the `VueNodeData` deletion, and the
`inputs[].link` reader migration. Out of scope: Slot entity extraction,
`Properties` (`properties` / `properties_info`) and `NodeType` metadata
beyond `type`/`apiNode` (`category`, `nodeData`, `description` remain on
the class/constructor), badges, `WidgetContainer` (already owned by
`widgetValueStore`), and command-pattern mutators (future work per
ADR 0003/0008).

View File

@@ -6,36 +6,20 @@ For the full problem analysis, see [Entity Problems](entity-problems.md). For th
## 1. What's Already Extracted
Nine dedicated stores extract entity state out of class instances into focused,
Six dedicated stores extract entity state out of class instances into focused,
queryable registries, each owning one concern. Promoted value-widget topology is
no longer a store; ADR 0009 represents it as ordinary linked `SubgraphInput`
state, and promoted value data lives in `WidgetValueStore` keyed by the input's
`WidgetId`.
| Store | Extracts From | Scoping | Key Format | Data Shape |
| ----------------------- | ---------------------------- | ----------------- | --------------------------------------------------------- | ----------------------------- |
| WidgetValueStore | `BaseWidget` | `graphId` | `WidgetId` (`graphId:nodeId:name`) | Plain `WidgetState` object |
| DomWidgetStore | `BaseDOMWidget` | Global | `widgetId` (UUID) | Position, visibility, z-index |
| LayoutStore | Node, Link geometry, Reroute | Workflow-level | `nodeId`, `linkId`, `rerouteId` | Y.js CRDT maps (pos, size) |
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
| PreviewExposureStore | Subgraph host node | host node locator | host locator + exposure name | Display-only preview state |
| LinkStore | `LLink` | Root graph | `` `${targetNodeId}:${targetSlot}` `` (target input slot) | Plain `LinkTopology` object |
| RerouteStore | `Reroute` | Root graph | `RerouteId` | Plain `RerouteChain` object |
| NodeBadgeStore | `LGraphNode.badges` closures | Root graph | `NodeId` | Plain `BadgeData` rows |
**Update (2026-07-05):** `LinkStore` (`src/stores/linkStore.ts`, PR #13436) and
`RerouteStore` (`src/stores/rerouteStore.ts`, PR #13449) hold plain-data records
in reactive `Map` buckets — not Y.js — scoped by root graph (subgraphs share
their root's bucket). Floating links and links targeting subgraph outputs live
in a per-graph unkeyed side set. Design records:
[Link Topology Store](link-topology-store.md),
[Reroute Chain Store](reroute-chain-store.md).
**Update (2026-07-14):** `NodeBadgeStore` (`src/stores/nodeBadgeStore.ts`,
PR #13458) holds plain `BadgeData` rows keyed by `NodeId` in root-graph-scoped
buckets, written by a reactive badge system rather than adopted class state.
Design record: [Node Badge Store](node-badge-store.md).
| Store | Extracts From | Scoping | Key Format | Data Shape |
| ----------------------- | ------------------- | ----------------- | ---------------------------------- | ----------------------------- |
| WidgetValueStore | `BaseWidget` | `graphId` | `WidgetId` (`graphId:nodeId:name`) | Plain `WidgetState` object |
| DomWidgetStore | `BaseDOMWidget` | Global | `widgetId` (UUID) | Position, visibility, z-index |
| LayoutStore | Node, Link, Reroute | Workflow-level | `nodeId`, `linkId`, `rerouteId` | Y.js CRDT maps (pos, size) |
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
| PreviewExposureStore | Subgraph host node | host node locator | host locator + exposure name | Display-only preview state |
ADR 0009 refines promoted-widget identity: promoted value widgets are keyed by
the host boundary (`host node locator + SubgraphInput.name`), while interior
@@ -165,37 +149,28 @@ The most architecturally advanced extraction — uses Y.js CRDTs for collaborati
```
ynodes: Y.Map<NodeLayoutMap> // nodeId → { pos, size, zIndex, bounds }
yreroutes: Y.Map<Y.Map<...>> // rerouteId → { id, position }
ylinks: Y.Map<Y.Map<...>> // linkId → link layout data
yreroutes: Y.Map<Y.Map<...>> // rerouteId → reroute layout data
```
**Update (2026-07-05):** The link-connectivity mirror (`ylinks`, `LinkData`,
`createLink`/`removeLink` mutations, `findLinksConnectedToNode`) was deleted
when link topology moved to `LinkStore` (PR #13436). LayoutStore now owns only
link/segment _geometry_ caches, and `RerouteData` carries `{ id, position }`
only — the write-only `parentId`/`linkIds` fields were removed.
### Write API
`useLayoutMutations()` (`src/renderer/core/layout/operations/layoutMutations.ts`) provides the mutation API:
- `moveNode(nodeId, pos)` / `batchMoveNodes(...)`
- `resizeNode(nodeId, size)`
- `setNodeZIndex(nodeId, zIndex)` / `bringNodeToFront(nodeId)`
- `createNode(nodeId, layout)` / `deleteNode(nodeId)`
- `createReroute(rerouteId, pos)` / `deleteReroute(rerouteId)` /
`moveReroute(rerouteId, pos, prevPos)`
(`createLink`/`removeLink` are gone — link topology is `LinkStore`'s concern.)
- `moveNode(graphId, nodeId, pos)`
- `resizeNode(graphId, nodeId, size)`
- `setNodeZIndex(graphId, nodeId, zIndex)`
- `createLink(graphId, linkId, ...)`
- `removeLink(graphId, linkId)`
- `moveReroute(graphId, rerouteId, pos)`
### The Scattered Access Problem
This composable is called at **module scope** in domain objects:
- `Reroute.ts:31` — `const layoutMutations = useLayoutMutations()`
- `LLink.ts:24``const layoutMutations = useLayoutMutations()`
- `Reroute.ts` — same pattern
- `LGraphNode.ts` — imported and called in methods
- `LLink.ts` no longer uses `useLayoutMutations` (its layout writes went away
with the `ylinks` mirror), but it still imports `layoutStore` and
`useLinkStore` at module scope
These module-scope calls create implicit dependencies on the Vue runtime and make the domain objects untestable without a full app context.
@@ -259,14 +234,12 @@ graph TD
Each store owns the identity scheme that fits its concern:
| Store | Key Format | Key Type | Type-Safe? |
| ---------------- | ----------------------------------------------------------- | ------------------ | ----------------- |
| WidgetValueStore | `WidgetId` (`graphId:nodeId:name`) | branded string | Yes (`WidgetId`) |
| DomWidgetStore | Widget UUID | UUID (string) | No |
| LayoutStore | Raw nodeId/linkId/rerouteId | Mixed number types | No |
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
| LinkStore | `` `${targetNodeId}:${targetSlot}` `` (root-scoped buckets) | Composite string | No |
| RerouteStore | `RerouteId` (root-scoped buckets) | branded number | Yes (`RerouteId`) |
| Store | Key Format | Key Type | Type-Safe? |
| ---------------- | ---------------------------------- | ------------------ | ---------------- |
| WidgetValueStore | `WidgetId` (`graphId:nodeId:name`) | branded string | Yes (`WidgetId`) |
| DomWidgetStore | Widget UUID | UUID (string) | No |
| LayoutStore | Raw nodeId/linkId/rerouteId | Mixed number types | No |
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
`WidgetValueStore` already keys on a branded `WidgetId` string (`src/types/widgetId.ts`),
which carries its scope and survives renames at the store layer. The remaining
@@ -313,26 +286,22 @@ graph TD
subgraph Link["LLink"]
L_ext["Extracted:
- id, endpoints, type, parentId → LinkStore
(LLink._state IS the store entry;
fields are accessors over it)
- segment geometry → LayoutStore"]
- layout data → LayoutStore"]
L_rem["Remains on class:
- color, path, _pos, _centreAngle
- origin_id, target_id
- origin_slot, target_slot
- type, color, path
- data, _dragging
- disconnect(), resolve()"]
end
subgraph Reroute["Reroute"]
R_ext["Extracted:
- pos → LayoutStore (partial mirror;
posInternal still truth)
- parentId, floating → RerouteStore
- linkIds, floatingLinkIds → derived
from links' parentId chains"]
- pos → LayoutStore"]
R_rem["Remains on class:
- posInternal (position truth)
- colour, draw()
- parentId, linkIds
- floatingLinkIds
- color, draw()
- findSourceOutput()"]
end
@@ -377,19 +346,15 @@ graph TD
What each entity needs to reach the ECS target from [ADR 0008](../adr/0008-entity-component-system.md):
| Entity | Already Extracted | Still on Class | ECS Target Components | Gap |
| ------------ | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
| **Node** | pos, size (LayoutStore) | type, visual, connectivity, execution, properties, widgets, rendering, serialization | Position, NodeVisual, NodeType, Connectivity, Execution, Properties, WidgetContainer | Large — 6 components unextracted, all behavior on class |
| **Link** | endpoints, type, parentId (LinkStore, via `_state` proxy); segment geometry (LayoutStore) | visual (color, path), drag state, connectivity methods | LinkEndpoints, LinkVisual, LinkState | Small — topology shipped (PR #13436); visual state and slot mirrors remain |
| **Widget** | value, label, disabled (WidgetValueStore); DOM state (DomWidgetStore) | node back-ref, rendering, events, layout | WidgetIdentity, WidgetValue, WidgetLayout | Small — value extraction done; rendering and layout remain |
| **Slot** | (nothing) | name, type, direction, link refs, visual, position | SlotIdentity, SlotConnection, SlotVisual | Full — no extraction started |
| **Reroute** | parentId, floating (RerouteStore); pos (LayoutStore, partial mirror) | position truth (posInternal), visual, chain traversal | Position, RerouteChain ✅, RerouteVisual | Small — chain shipped (PR #13449); position ownership and visual remain |
| **Group** | (nothing) | pos, size, meta, visual, children | Position, GroupMeta, GroupVisual, GroupChildren | Full — no extraction started |
| **Subgraph** | promoted value exposure (linked inputs); preview exposure (PreviewExposureStore) | structure, meta, I/O, all LGraph state | SubgraphStructure, SubgraphMeta (as node components) | Large — mostly unextracted; subgraph is a node with components, not a separate entity kind |
`RerouteChain` supersedes the earlier `RerouteLinks` component (ADR 0008
amendment, 2026-07-04): link membership is never stored — it is derived from
the links' `parentId` chains over `LinkStore`.
| Entity | Already Extracted | Still on Class | ECS Target Components | Gap |
| ------------ | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
| **Node** | pos, size (LayoutStore) | type, visual, connectivity, execution, properties, widgets, rendering, serialization | Position, NodeVisual, NodeType, Connectivity, Execution, Properties, WidgetContainer | Large — 6 components unextracted, all behavior on class |
| **Link** | layout (LayoutStore) | endpoints, visual, state, connectivity methods | LinkEndpoints, LinkVisual, LinkState | Medium — 3 components unextracted |
| **Widget** | value, label, disabled (WidgetValueStore); DOM state (DomWidgetStore) | node back-ref, rendering, events, layout | WidgetIdentity, WidgetValue, WidgetLayout | Small — value extraction done; rendering and layout remain |
| **Slot** | (nothing) | name, type, direction, link refs, visual, position | SlotIdentity, SlotConnection, SlotVisual | Full — no extraction started |
| **Reroute** | pos (LayoutStore) | links, visual, chain traversal | Position, RerouteLinks, RerouteVisual | Medium — position done, rest unextracted |
| **Group** | (nothing) | pos, size, meta, visual, children | Position, GroupMeta, GroupVisual, GroupChildren | Full — no extraction started |
| **Subgraph** | promoted value exposure (linked inputs); preview exposure (PreviewExposureStore) | structure, meta, I/O, all LGraph state | SubgraphStructure, SubgraphMeta (as node components) | Large — mostly unextracted; subgraph is a node with components, not a separate entity kind |
### Priority Order for Extraction
@@ -397,11 +362,8 @@ Based on existing progress and problem severity:
1. **Widget** — closest to done (value extraction complete, needs rendering/layout extraction)
2. **Node Position** — already in LayoutStore, needs branded ID and formal component type
3. **Link** — ✅ topology shipped (LinkStore, PR #13436); slot mirrors
(`input.link`/`output.links`) and visual state remain
3. **Link**small component set, high coupling pain
4. **Slot** — no extraction yet, but small and self-contained
(`SlotConnection` input side now answerable via LinkStore)
5. **Reroute** — ✅ chain shipped (RerouteStore, PR #13449); position
ownership and visual remain
5. **Reroute** — partially extracted, moderate complexity
6. **Group** — no extraction, but least coupled to other entities
7. **Subgraph** — not a separate entity kind; SubgraphStructure and SubgraphMeta become node components. Depends on Node and Link extraction first. See [Subgraph Boundaries](subgraph-boundaries-and-promotion.md)

View File

@@ -1,118 +0,0 @@
# Reroute Chain Store
Date: 2026-07-04
Status: Accepted (design review; follow-up to the
[link topology store](link-topology-store.md), PR #13436)
Design record for extracting reroute connectivity state into a dedicated
store per [ADR 0008](../adr/0008-entity-component-system.md). Amends the
`RerouteLinks` component described there.
## Decision 1: The chain is the single source of truth for membership
"Which links pass through reroute R" is encoded twice today: each link's
`parentId` chain (`link.parentId` names the terminal reroute;
`reroute.parentId` walks upstream), and per-reroute `linkIds` /
`floatingLinkIds` Sets maintained by hand at roughly ten write sites
(`LGraphNode.connect`, `LLink.disconnect`, `LGraph.addFloatingLink` /
`removeFloatingLink` / `createReroute`, subgraph unpack,
`SubgraphInput/Output.connect`, `LinkConnector`), with
`Reroute.validateLinks` repairing drift at configure time.
The chain is primary. Membership becomes a derived reverse index:
```
linksThrough(R) = { L : R ∈ chain(L.parentId) }
```
computed over the link store's topologies plus the reroute chain states,
cached, and invalidated on chain mutation. The `Reroute` class exposes
`linkIds` / `floatingLinkIds` as derived accessors. The membership write
sites and `validateLinks` are deleted.
Rejected: storing membership Sets in the store (the `LLink._state`
pattern applied to whole membership). It keeps one _stored_ copy but
preserves the domain-level redundancy — chain and membership can still
disagree, and every dual write site survives.
## Decision 2: Store shape and keying
`rerouteStore` holds per-reroute chain state objects, registered by
reference (the class reads through them, mirroring `LLink._state`):
```
RerouteChain { id, parentId?, floating? }
```
Buckets are root-graph-scoped (`rootGraph.id`), keyed by `RerouteId`,
matching `widgetValueStore` and `linkStore` scoping. Owning-graph buckets
were evaluated and rejected for the link store (2026-07-04) and are
rejected here for the same reasons.
`RerouteId` is the domain key because runtime allocation is already
per-root-unique: `Subgraph.state` delegates to the root graph's state, so
all graphs increment one shared `lastRerouteId` counter.
## Decision 3: Load-time reroute-id dedup
Serialized workflows from older frontends or external tools can carry
colliding reroute ids across sibling subgraph definitions. Today this is
tolerated only because `graph.reroutes` is a per-graph map; a root-scoped
bucket would break on it, and the layout store's bare-`rerouteId` keying
already collides latently.
On configure, colliding subgraph reroute ids are rewritten to fresh ids
from the shared counter, patching that subgraph's `link.parentId` and
`reroute.parentId` references — the same repair the node-id and link-id
dedup passes already perform. Rewritten ids serialize back changed.
Rejected: a first-wins registration protocol (losers detached via an
ownership flag). That is the apparatus the target-keyed link store
redesign existed to delete.
## Decision 4: Registration returns the reactive proxy
The derived membership index requires observable chain mutation.
`BaseWidget` already establishes the pattern: the store bucket is a
`reactive(Map)`, registration inserts the raw state and returns the
value read back from the map — the reactive proxy — and the class holds
that proxy as `_state` (`BaseWidget.setNodeId`,
`widgetValueStore.registerWidget`). Every subsequent class write goes
through the proxy and is tracked.
`rerouteStore.registerReroute` follows this: it returns the proxy and
the `Reroute` class reads and writes chain state through it, so
`reroute.parentId` mutations are tracked with no action chokepoint.
`LLink` previously deviated — `registerLinkTopology` left `link._state`
raw, which is why `linkStore.updateEndpoint` must re-wrap with
`reactive()` before patching, and why bare `link.parentId` writes were
invisible to effects. This migration aligns it with the `BaseWidget`
pattern: link registration re-assigns `_state` to the store proxy,
making `link.parentId` writes tracked. `updateEndpoint` keeps its
`reactive()` wrap as a guard — the store is public API and may be
handed a raw topology object.
## Decision 5: Serialization
`SerialisableReroute.linkIds` remains in the wire format, emitted from
the derived membership in ascending link-id order. All runtime producers
append links in ascending id order and the serialization goldens hold
ascending arrays, so chain-consistent workflows round-trip byte-identical.
Workflows whose stored `linkIds` contradict their chains are repaired on
the next save (membership re-derived, stale ids dropped, order
normalized). A reroute that no chain reaches at all is dropped at load —
where `validateLinks` used to preserve it if its stored ids named live
links — because the chain is primary. No compatibility shim for such
files. `floatingLinkIds` stays unserialized, rebuilt at runtime as
before.
## Scope
This design covers chain state, derived membership, and the `LLink`
proxy retrofit from Decision 4 (a small self-contained change, done
first). Reroute visual state (`_colour`, badge) is out of scope. The
`Reroute.pos` class field still mirrors the layout store's position;
that pre-existing duplication is a separate concern, not addressed
here.

View File

@@ -12,6 +12,11 @@ export type {
AddAssetTagsErrors,
AddAssetTagsResponse,
AddAssetTagsResponses,
AdminDeleteHubWorkflowData,
AdminDeleteHubWorkflowError,
AdminDeleteHubWorkflowErrors,
AdminDeleteHubWorkflowResponse,
AdminDeleteHubWorkflowResponses,
Asset,
AssetCreated,
AssetCreatedWritable,
@@ -42,6 +47,11 @@ export type {
CancelJobErrors,
CancelJobResponse,
CancelJobResponses,
CancelJobsData,
CancelJobsError,
CancelJobsErrors,
CancelJobsResponse,
CancelJobsResponses,
CancelSubscriptionData,
CancelSubscriptionError,
CancelSubscriptionErrors,
@@ -84,6 +94,11 @@ export type {
CreateDeletionRequestErrors,
CreateDeletionRequestResponse,
CreateDeletionRequestResponses,
CreateDesktopLoginCodeData,
CreateDesktopLoginCodeError,
CreateDesktopLoginCodeErrors,
CreateDesktopLoginCodeResponse,
CreateDesktopLoginCodeResponses,
CreateHubAssetUploadUrlData,
CreateHubAssetUploadUrlError,
CreateHubAssetUploadUrlErrors,
@@ -186,12 +201,31 @@ export type {
DeleteWorkspaceResponses,
DeletionRequest,
DeletionStatus,
DesktopLoginCodeCreateRequest,
DesktopLoginCodeCreateResponse,
DesktopLoginCodeExchangeRequest,
DesktopLoginCodeExchangeResponse,
DesktopLoginCodeRedeemRequest,
DesktopLoginCodeRedeemResponse,
DownloadExportData,
DownloadExportError,
DownloadExportErrors,
DownloadExportResponse,
DownloadExportResponses,
EnsureWorkspaceBillingLegacySnapshot,
EnsureWorkspaceBillingProvisionedData,
EnsureWorkspaceBillingProvisionedError,
EnsureWorkspaceBillingProvisionedErrors,
EnsureWorkspaceBillingProvisionedRequest,
EnsureWorkspaceBillingProvisionedResponse,
EnsureWorkspaceBillingProvisionedResponse2,
EnsureWorkspaceBillingProvisionedResponses,
ErrorResponse,
ExchangeDesktopLoginCodeData,
ExchangeDesktopLoginCodeError,
ExchangeDesktopLoginCodeErrors,
ExchangeDesktopLoginCodeResponse,
ExchangeDesktopLoginCodeResponses,
ExchangeTokenData,
ExchangeTokenError,
ExchangeTokenErrors,
@@ -230,6 +264,11 @@ export type {
GetAssetByIdErrors,
GetAssetByIdResponse,
GetAssetByIdResponses,
GetAssetContentData,
GetAssetContentError,
GetAssetContentErrors,
GetAssetContentResponse,
GetAssetContentResponses,
GetAssetSeedStatusData,
GetAssetSeedStatusResponse,
GetAssetSeedStatusResponses,
@@ -303,6 +342,11 @@ export type {
GetHistoryData,
GetHistoryError,
GetHistoryErrors,
GetHistoryEventsData,
GetHistoryEventsError,
GetHistoryEventsErrors,
GetHistoryEventsResponse,
GetHistoryEventsResponses,
GetHistoryForPromptData,
GetHistoryForPromptError,
GetHistoryForPromptErrors,
@@ -345,8 +389,6 @@ export type {
GetJwksData,
GetJwksResponse,
GetJwksResponses,
GetLegacyAssetContentData,
GetLegacyAssetContentErrors,
GetLegacyHistoryByIdData,
GetLegacyHistoryByIdErrors,
GetLegacyHistoryData,
@@ -556,6 +598,7 @@ export type {
HistoryDetailEntry,
HistoryDetailResponse,
HistoryEntry,
HistoryEventRequest,
HistoryManageRequest,
HistoryResponse,
HubAssetUploadUrlRequest,
@@ -589,6 +632,8 @@ export type {
JobCancelResponse,
JobDetailResponse,
JobEntry,
JobsCancelRequest,
JobsCancelResponse,
JobsListResponse,
JobStatusResponse,
JwkKey,
@@ -627,7 +672,19 @@ export type {
ListJobsErrors,
ListJobsResponse,
ListJobsResponses,
ListLinkedFirebaseUidsData,
ListLinkedFirebaseUidsError,
ListLinkedFirebaseUidsErrors,
ListLinkedFirebaseUidsRequest,
ListLinkedFirebaseUidsResponse,
ListLinkedFirebaseUidsResponse2,
ListLinkedFirebaseUidsResponses,
ListMembersResponse,
ListSecretProvidersData,
ListSecretProvidersError,
ListSecretProvidersErrors,
ListSecretProvidersResponse,
ListSecretProvidersResponses,
ListSecretsData,
ListSecretsError,
ListSecretsErrors,
@@ -775,6 +832,17 @@ export type {
QueueInfo,
QueueManageRequest,
QueueManageResponse,
RedeemDesktopLoginCodeData,
RedeemDesktopLoginCodeError,
RedeemDesktopLoginCodeErrors,
RedeemDesktopLoginCodeResponse,
RedeemDesktopLoginCodeResponses,
ReleaseDeletionHoldData,
ReleaseDeletionHoldError,
ReleaseDeletionHoldErrors,
ReleaseDeletionHoldResponse,
ReleaseDeletionHoldResponses,
ReleaseHoldResponse,
RemoveAssetTagsData,
RemoveAssetTagsError,
RemoveAssetTagsErrors,
@@ -785,6 +853,11 @@ export type {
RemoveWorkspaceMemberErrors,
RemoveWorkspaceMemberResponse,
RemoveWorkspaceMemberResponses,
ReportHistoryEventData,
ReportHistoryEventError,
ReportHistoryEventErrors,
ReportHistoryEventResponse,
ReportHistoryEventResponses,
ReportPartnerUsageData,
ReportPartnerUsageError,
ReportPartnerUsageErrors,
@@ -808,6 +881,8 @@ export type {
RevokeWorkspaceInviteResponse,
RevokeWorkspaceInviteResponses,
SecretListResponse,
SecretProvider,
SecretProvidersResponse,
SecretResponse,
SeedAssetsData,
SeedAssetsResponse,
@@ -819,6 +894,8 @@ export type {
SetReviewStatusResponse,
SetReviewStatusResponse2,
SetReviewStatusResponses,
ShortLinkRedirectData,
ShortLinkRedirectErrors,
SubmitFeedbackData,
SubmitFeedbackError,
SubmitFeedbackErrors,
@@ -848,6 +925,10 @@ export type {
TaskEntry,
TaskResponse,
TasksListResponse,
TeamCreditStop,
TeamCreditStopPrice,
TeamCreditStops,
TeamCreditStopSummary,
UpdateAssetData,
UpdateAssetError,
UpdateAssetErrors,
@@ -865,6 +946,7 @@ export type {
UpdateHubWorkflowRequest,
UpdateHubWorkflowResponse,
UpdateHubWorkflowResponses,
UpdateMemberRoleRequest,
UpdateMultipleSettingsData,
UpdateMultipleSettingsError,
UpdateMultipleSettingsErrors,
@@ -895,6 +977,11 @@ export type {
UpdateWorkspaceData,
UpdateWorkspaceError,
UpdateWorkspaceErrors,
UpdateWorkspaceMemberRoleData,
UpdateWorkspaceMemberRoleError,
UpdateWorkspaceMemberRoleErrors,
UpdateWorkspaceMemberRoleResponse,
UpdateWorkspaceMemberRoleResponses,
UpdateWorkspaceRequest,
UpdateWorkspaceResponse,
UpdateWorkspaceResponses,

File diff suppressed because it is too large Load Diff

View File

@@ -465,6 +465,20 @@ export const zCreateWorkflowRequest = z.object({
forked_from_workflow_version_id: z.string().optional()
})
/**
* Request body for forwarding a comfy-api audit/history event. Identify the target workspace by either user_id (cloud resolves the user's personal workspace via the converged identity, BE-1047) or an explicit workspace_id. At least one must be provided; workspace_id wins when both are set.
*/
export const zHistoryEventRequest = z.object({
user_id: z.string().optional(),
workspace_id: z.string().optional(),
event_type: z.string().min(1),
event_id: z.string().min(1),
params: z.record(z.unknown()).optional(),
auth_method: z.enum(['api_key', 'bearer_token']).optional(),
customer_ref: z.string().optional(),
timestamp: z.string().datetime().optional()
})
/**
* Response after recording partner usage data.
*/
@@ -540,11 +554,11 @@ export const zPaymentPortalRequest = z.object({
})
/**
* Response after successfully resubscribing to a billing plan.
* Response after accepting a resubscribe request.
*/
export const zResubscribeResponse = z.object({
billing_op_id: z.string(),
status: z.enum(['active']),
status: z.enum(['active', 'pending']),
message: z.string().optional()
})
@@ -585,6 +599,8 @@ export const zSubscribeResponse = z.object({
*/
export const zSubscribeRequest = z.object({
plan_slug: z.string(),
team_credit_stop_id: z.string().optional(),
billing_cycle: z.enum(['monthly', 'yearly']).optional(),
idempotency_key: z.string().optional(),
return_url: z.string().optional(),
cancel_url: z.string().optional()
@@ -626,7 +642,8 @@ export const zSubscriptionTier = z.enum([
'STANDARD',
'CREATOR',
'PRO',
'FOUNDERS_EDITION'
'FOUNDERS_EDITION',
'TEAM'
])
/**
@@ -714,6 +731,57 @@ export const zPreviewSubscribeRequest = z.object({
plan_slug: z.string()
})
/**
* Pre/post-discount price for a team credit stop, in cents.
*/
export const zTeamCreditStopPrice = z.object({
list_price_cents: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
}),
price_cents: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
})
})
/**
* A selectable preset on the team pricing slider. Echoed on subscribe via
* team_credit_stop_id; the backend owns the resolved amounts. credits is a
* RAW monthly credit count (not cents). Save% is derived by the FE as
* (list_price_cents - price_cents) / list_price_cents.
*
*/
export const zTeamCreditStop = z.object({
id: z.string(),
credits: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
}),
monthly: zTeamCreditStopPrice,
yearly: zTeamCreditStopPrice
})
/**
* Credit-stop ladder for the pricing slider (BE-1254). Returned by GET /api/billing/plans for every workspace regardless of the caller's token or workspace type (the personal/team distinction was removed); omitted only when the catalog defines no stops.
*/
export const zTeamCreditStops = z.object({
default_stop_index: z.number().int(),
stops: z.array(zTeamCreditStop)
})
/**
* Reason why a plan is unavailable
*/
@@ -773,7 +841,50 @@ export const zPlan = z.object({
*/
export const zBillingPlansResponse = z.object({
current_plan_slug: z.string().optional(),
plans: z.array(zPlan)
plans: z.array(zPlan),
team_credit_stops: zTeamCreditStops.optional()
})
/**
* The team credit stop a workspace is currently subscribed to: the
* per-workspace slider choice recorded at subscribe time
* (workspace_subscriptions.team_credit_stop_id). Amounts are owned by the
* catalog, not the subscription row. Returned on GET /api/billing/status
* for per-credit Team plans (BE-1254).
*
*/
export const zTeamCreditStopSummary = z.object({
id: z.string(),
credits_monthly: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
}),
stop_usd: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
})
})
/**
* A provider the user may configure a secret for. The shape is deliberately minimal (identifier only) and reserved for future per-provider fields such as sub-keys.
*/
export const zSecretProvider = z.object({
id: z.string()
})
/**
* The providers available to the authenticated user in the current workspace.
*/
export const zSecretProvidersResponse = z.object({
data: z.array(zSecretProvider)
})
/**
@@ -813,7 +924,7 @@ export const zCreateSecretRequest = z.object({
})
/**
* A single billing event such as a charge, credit, or adjustment.
* A single history event. The cloud history-events store is the single source of truth for both billing events (charges, credits, adjustments) and user-facing usage events.
*/
export const zBillingEvent = z.object({
event_type: z.string(),
@@ -868,7 +979,8 @@ export const zBillingStatusResponse = z.object({
billing_status: zBillingStatus.optional(),
has_funds: z.boolean(),
cancel_at: z.string().datetime().optional(),
renewal_date: z.string().datetime().optional()
renewal_date: z.string().datetime().optional(),
team_credit_stop: zTeamCreditStopSummary.nullable()
})
/**
@@ -930,6 +1042,7 @@ export const zOAuthConsentChallenge = z.object({
csrf_token: z.string(),
client_display_name: z.string(),
resource_display_name: z.string(),
redirect_uri: z.string().url(),
scopes: z.array(z.string()),
workspaces: z.array(zOAuthConsentChallengeWorkspace)
})
@@ -1056,6 +1169,66 @@ export const zSyncApiKeyRequest = z.object({
customer_id: z.string().min(1)
})
/**
* The personal workspace's provisioned billing identity.
*/
export const zEnsureWorkspaceBillingProvisionedResponse = z.object({
workspace_id: z.string(),
stripe_customer_id: z.string(),
metronome_customer_id: z.string(),
metronome_contract_id: z.string()
})
/**
* The caller's already-resolved legacy (comfy-api) customer identity. When
* present and carrying provider IDs, provisioning ATTACHES this identity to
* the personal workspace (sharing the existing balance and subscription)
* instead of minting a net-new empty customer. Omit (or send with no
* provider IDs) for a free user with nothing to attach — provisioning then
* creates net-new. This closes the create-new-before-attach gap: a caller
* that already knows the legacy identity hands it over so the very first
* provisioning is an attach.
*
*/
export const zEnsureWorkspaceBillingLegacySnapshot = z.object({
stripe_customer_id: z.string().optional(),
metronome_customer_id: z.string().optional(),
metronome_contract_id: z.string().optional(),
has_funds: z.boolean().optional(),
subscription_tier: z.string().optional(),
legacy_stripe_subscription_id: z.string().optional(),
legacy_comfy_user_id: z.string().optional()
})
/**
* Request body for ensuring a user's personal workspace carries a fully
* provisioned billing identity. Sent by comfy-api's CreateCustomer (BE-1047)
* with the already canonical-resolved user identity.
*
*/
export const zEnsureWorkspaceBillingProvisionedRequest = z.object({
user_id: z.string().min(1),
email: z.string().email().min(1),
snapshot: zEnsureWorkspaceBillingLegacySnapshot.optional()
})
/**
* Firebase UIDs linked to the canonical comfy_user_id. Empty list when
* no mappings exist (not an error — callers can treat empty as "unknown
* canonical").
*
*/
export const zListLinkedFirebaseUidsResponse = z.object({
firebase_uids: z.array(z.string())
})
/**
* Request body for reverse-looking-up Firebase UIDs linked to a canonical comfy_user_id.
*/
export const zListLinkedFirebaseUidsRequest = z.object({
comfy_user_id: z.string().min(1)
})
/**
* Response confirming the validity and scope of a workspace API key.
*/
@@ -1172,7 +1345,8 @@ export const zMember = z.object({
name: z.string(),
email: z.string().email(),
role: z.enum(['owner', 'member']),
joined_at: z.string().datetime()
joined_at: z.string().datetime(),
is_original_owner: z.boolean()
})
/**
@@ -1183,6 +1357,13 @@ export const zListMembersResponse = z.object({
pagination: zPaginationInfo
})
/**
* Request body for changing a workspace member's role.
*/
export const zUpdateMemberRoleRequest = z.object({
role: z.enum(['owner', 'member'])
})
/**
* Request body for updating an existing workspace's settings.
*/
@@ -1227,6 +1408,60 @@ export const zWorkspace = z.object({
created_at: z.string().datetime()
})
/**
* Exchange poll result. Pending until the code is redeemed in the browser.
*/
export const zDesktopLoginCodeExchangeResponse = z.object({
status: z.enum(['pending', 'complete']),
custom_token: z.string().optional()
})
/**
* Request to exchange a redeemed login code for a custom token.
*/
export const zDesktopLoginCodeExchangeRequest = z.object({
code: z.string(),
code_verifier: z.string().min(43).max(128)
})
/**
* Result of redeeming a desktop login code.
*/
export const zDesktopLoginCodeRedeemResponse = z.object({
status: z.enum(['redeemed'])
})
/**
* Request to claim a desktop login code for the authenticated user.
*/
export const zDesktopLoginCodeRedeemRequest = z.object({
code: z.string()
})
/**
* A freshly minted desktop login code and its polling parameters.
*/
export const zDesktopLoginCodeCreateResponse = z.object({
code: z.string(),
expires_in: z.number().int(),
poll_interval: z.number().int()
})
/**
* Request to mint a desktop login code.
*/
export const zDesktopLoginCodeCreateRequest = z.object({
installation_id: z
.string()
.min(8)
.max(128)
.regex(/^[A-Za-z0-9._-]+$/)
.optional(),
platform: z.string().min(1).max(32),
app_version: z.string().min(1).max(64),
code_challenge: z.string().min(43).max(128)
})
/**
* Abbreviated workspace metadata used in list responses.
*/
@@ -1294,6 +1529,15 @@ export const zTasksListResponse = z.object({
pagination: zPaginationInfo
})
/**
* Result of authorizing a legal-hold release on a user's deletion.
*/
export const zReleaseHoldResponse = z.object({
firebase_id: z.string(),
released: z.boolean(),
message: z.string()
})
/**
* Current status of a user data deletion request.
*/
@@ -1363,6 +1607,20 @@ export const zJobDetailResponse = z.object({
execution_meta: z.record(z.unknown()).optional()
})
/**
* Response for POST /api/jobs/cancel.
*/
export const zJobsCancelResponse = z.object({
cancelled: z.array(z.string())
})
/**
* Request to cancel multiple jobs by ID.
*/
export const zJobsCancelRequest = z.object({
job_ids: z.array(z.string().uuid()).min(1).max(100)
})
/**
* Response for POST /api/jobs/{job_id}/cancel. Returned on both fresh cancels and idempotent no-ops.
*/
@@ -1529,6 +1787,7 @@ export const zAsset = z.object({
user_metadata: z.record(z.unknown()).optional(),
metadata: z.record(z.unknown()).readonly().optional(),
preview_url: z.string().url().optional(),
short_url: z.string().nullish(),
preview_id: z.string().uuid().nullish(),
job_id: z.string().uuid().nullish(),
created_at: z.string().datetime(),
@@ -1624,6 +1883,7 @@ export const zSystemStatsResponse = z.object({
python_version: z.string(),
embedded_python: z.boolean(),
comfyui_version: z.string(),
deploy_environment: z.string().optional(),
comfyui_frontend_version: z.string().optional(),
workflow_templates_version: z.string().optional(),
cloud_version: z.string().optional(),
@@ -1962,6 +2222,7 @@ export const zAssetWritable = z.object({
tags: z.array(z.string()).optional(),
user_metadata: z.record(z.unknown()).optional(),
preview_url: z.string().url().optional(),
short_url: z.string().nullish(),
preview_id: z.string().uuid().nullish(),
job_id: z.string().uuid().nullish(),
created_at: z.string().datetime(),
@@ -2180,7 +2441,11 @@ export const zGetJobDetailData = z.object({
path: z.object({
job_id: z.string().uuid()
}),
query: z.never().optional()
query: z
.object({
short_link: z.enum(['ephemeral_tool_chain', 'default']).optional()
})
.optional()
})
/**
@@ -2201,6 +2466,17 @@ export const zCancelJobData = z.object({
*/
export const zCancelJobResponse = zJobCancelResponse
export const zCancelJobsData = z.object({
body: zJobsCancelRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Success - cancel requests dispatched (or jobs were already terminal)
*/
export const zCancelJobsResponse = zJobsCancelResponse
export const zViewFileData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -2580,6 +2856,17 @@ export const zCreateSecretData = z.object({
*/
export const zCreateSecretResponse = zSecretResponse
export const zListSecretProvidersData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Success
*/
export const zListSecretProvidersResponse = zSecretProvidersResponse
export const zDeleteSecretData = z.object({
body: z.never().optional(),
path: z.object({
@@ -2881,6 +3168,40 @@ export const zExchangeTokenData = z.object({
*/
export const zExchangeTokenResponse2 = zExchangeTokenResponse
export const zCreateDesktopLoginCodeData = z.object({
body: zDesktopLoginCodeCreateRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Login code created
*/
export const zCreateDesktopLoginCodeResponse = zDesktopLoginCodeCreateResponse
export const zRedeemDesktopLoginCodeData = z.object({
body: zDesktopLoginCodeRedeemRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Code redeemed (or already redeemed by the same user)
*/
export const zRedeemDesktopLoginCodeResponse = zDesktopLoginCodeRedeemResponse
export const zExchangeDesktopLoginCodeData = z.object({
body: zDesktopLoginCodeExchangeRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Pending (not yet redeemed) or complete with a custom token
*/
export const zExchangeDesktopLoginCodeResponse =
zDesktopLoginCodeExchangeResponse
export const zGetJwksData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -3150,6 +3471,19 @@ export const zRemoveWorkspaceMemberData = z.object({
*/
export const zRemoveWorkspaceMemberResponse = z.void()
export const zUpdateWorkspaceMemberRoleData = z.object({
body: zUpdateMemberRoleRequest,
path: z.object({
userId: z.string()
}),
query: z.never().optional()
})
/**
* Member role updated
*/
export const zUpdateWorkspaceMemberRoleResponse = zMember
export const zListWorkspaceApiKeysData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -3236,6 +3570,19 @@ export const zSetReviewStatusData = z.object({
*/
export const zSetReviewStatusResponse2 = zSetReviewStatusResponse
export const zAdminDeleteHubWorkflowData = z.object({
body: z.never().optional(),
path: z.object({
share_id: z.string()
}),
query: z.never().optional()
})
/**
* Successfully deleted
*/
export const zAdminDeleteHubWorkflowResponse = z.void()
export const zUpdateHubWorkflowData = z.object({
body: zUpdateHubWorkflowRequest,
path: z.object({
@@ -3277,6 +3624,19 @@ export const zCreateDeletionRequestResponse = z.object({
user_found_in_cloud: z.boolean()
})
export const zReleaseDeletionHoldData = z.object({
body: z.object({
firebase_id: z.string()
}),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Release authorized; the deletion workflow will proceed
*/
export const zReleaseDeletionHoldResponse = zReleaseHoldResponse
export const zReportPartnerUsageData = z.object({
body: zPartnerUsageRequest,
path: z.never().optional(),
@@ -3288,6 +3648,38 @@ export const zReportPartnerUsageData = z.object({
*/
export const zReportPartnerUsageResponse = zPartnerUsageResponse
export const zGetHistoryEventsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z
.object({
workspace_id: z.string().optional(),
user_id: z.string().optional(),
event_type: z.string().optional(),
start_date: z.string().datetime().optional(),
end_date: z.string().datetime().optional(),
page: z.number().int().optional(),
limit: z.number().int().optional()
})
.optional()
})
/**
* Paginated cloud history events for the workspace
*/
export const zGetHistoryEventsResponse = zBillingEventsResponse
export const zReportHistoryEventData = z.object({
body: zHistoryEventRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* History event recorded successfully
*/
export const zReportHistoryEventResponse = zPartnerUsageResponse
export const zUpdateSubscriptionCacheData = z.object({
body: z.object({
user_id: z.string(),
@@ -3305,6 +3697,29 @@ export const zUpdateSubscriptionCacheResponse = z.object({
status: z.string().optional()
})
export const zListLinkedFirebaseUidsData = z.object({
body: zListLinkedFirebaseUidsRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Linked Firebase UIDs (possibly empty list)
*/
export const zListLinkedFirebaseUidsResponse2 = zListLinkedFirebaseUidsResponse
export const zEnsureWorkspaceBillingProvisionedData = z.object({
body: zEnsureWorkspaceBillingProvisionedRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* The workspace's provisioned billing identity
*/
export const zEnsureWorkspaceBillingProvisionedResponse2 =
zEnsureWorkspaceBillingProvisionedResponse
export const zInsertDynamicConfigData = z.object({
body: z.record(z.unknown()),
path: z.never().optional(),
@@ -4010,6 +4425,14 @@ export const zGetModelPreviewData = z.object({
query: z.never().optional()
})
export const zShortLinkRedirectData = z.object({
body: z.never().optional(),
path: z.object({
id: z.string()
}),
query: z.never().optional()
})
export const zGetLegacyPromptByIdData = z.object({
body: z.never().optional(),
path: z.object({
@@ -4070,14 +4493,23 @@ export const zGetLegacyUserdataV2Data = z.object({
query: z.never().optional()
})
export const zGetLegacyAssetContentData = z.object({
export const zGetAssetContentData = z.object({
body: z.never().optional(),
path: z.object({
id: z.string()
}),
query: z.never().optional()
query: z
.object({
disposition: z.enum(['inline', 'attachment']).optional()
})
.optional()
})
/**
* Asset content stream (local runtime streams the bytes directly)
*/
export const zGetAssetContentResponse = z.string()
export const zGetLegacyViewMetadataData = z.object({
body: z.never().optional(),
path: z.object({

View File

@@ -4,5 +4,5 @@
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*", "*.config.ts"]
"include": ["src/**/*"]
}

View File

@@ -4,5 +4,5 @@
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*", "vitest.config.ts"]
"include": ["src/**/*"]
}

View File

@@ -35,10 +35,10 @@
:class="
sidebarLocation === 'left'
? cn(
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
'side-bar-panel pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden',
sidebarPanelVisible && 'min-w-78'
)
: 'pointer-events-auto bg-comfy-menu-bg'
: 'pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden'
"
:min-size="
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
@@ -82,7 +82,7 @@
</SplitterPanel>
<SplitterPanel
v-show="bottomPanelVisible && !focusMode"
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg"
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg focus-visible:outline-hidden"
>
<slot name="bottom-panel" />
</SplitterPanel>
@@ -95,10 +95,10 @@
:class="
sidebarLocation === 'right'
? cn(
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
'side-bar-panel pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden',
sidebarPanelVisible && 'min-w-78'
)
: 'pointer-events-auto bg-comfy-menu-bg'
: 'pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden'
"
:min-size="
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE

View File

@@ -11,8 +11,6 @@ import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { deriveWidgetRenderState } from '@/lib/litegraph/src/utils/widget'
import type { WidgetId } from '@/types/widgetId'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { extractWidgetStringValue } from '@/composables/maskeditor/useMaskEditorLoader'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
@@ -21,8 +19,6 @@ import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.v
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useLinkStore } from '@/stores/linkStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { parseImageWidgetValue } from '@/utils/imageUtil'
import { cn } from '@comfyorg/tailwind-utils'
@@ -33,8 +29,9 @@ import { promptRenameWidget } from '@/utils/widgetUtil'
interface WidgetEntry {
key: string
persistedHeight: number | undefined
nodeData: ReturnType<typeof nodeToNodeData>
widgetIds: readonly WidgetId[]
nodeData: ReturnType<typeof nodeToNodeData> & {
widgets: NonNullable<ReturnType<typeof nodeToNodeData>['widgets']>
}
action: { widget: IBaseWidget; node: LGraphNode }
}
@@ -46,8 +43,6 @@ const { mobile = false, builderMode = false } = defineProps<{
const { t } = useI18n()
const executionErrorStore = useExecutionErrorStore()
const appModeStore = useAppModeStore()
const widgetValueStore = useWidgetValueStore()
const linkStore = useLinkStore()
const maskEditor = useMaskEditor()
const { onPointerDown } = useAppModeWidgetResizing((widget, config) =>
@@ -59,61 +54,49 @@ provide(WidgetHeightKey, mobile ? 'h-10' : 'h-7')
const resolvedInputs = useResolvedSelectedInputs()
function ensureSelectedWidgetState(
widgetId: WidgetId,
widget: IBaseWidget
): void {
if (widgetValueStore.getWidget(widgetId)) return
widgetValueStore.registerWidget(
widgetId,
{
type: widget.type,
value: widget.value,
options: widget.options,
label: widget.label,
serialize: widget.serialize,
disabled: widget.disabled
},
deriveWidgetRenderState(widget)
)
}
function isWidgetInputLinked(node: LGraphNode, widgetName: string): boolean {
const graphId = node.graph?.rootGraph.id
const slot = node.inputs?.findIndex((i) => i.widget?.name === widgetName)
if (!graphId || slot === undefined || slot < 0) return false
return linkStore.isInputSlotConnected(graphId, node.id, slot)
}
const mappedSelections = computed((): WidgetEntry[] => {
const nodeDataByNode = new Map<
LGraphNode,
ReturnType<typeof nodeToNodeData>
>()
return resolvedInputs.value.flatMap((entry) => {
if (entry.status !== 'resolved') return []
const { widgetId, node, widget, config } = entry
if (node.mode !== LGraphEventMode.ALWAYS) return []
ensureSelectedWidgetState(widgetId, widget)
const fullNodeData = nodeToNodeData(node, widgetId)
if (isWidgetInputLinked(node, widget.name)) return []
if (!nodeDataByNode.has(node)) {
nodeDataByNode.set(node, nodeToNodeData(node))
}
const fullNodeData = nodeDataByNode.get(node)!
const matchingWidget = fullNodeData.widgets?.find((vueWidget) => {
if (vueWidget.slotMetadata?.linked) return false
return vueWidget.widgetId === widgetId
})
if (!matchingWidget) return []
matchingWidget.slotMetadata = undefined
matchingWidget.nodeId = node.id
return [
{
key: widgetId,
persistedHeight: config?.height,
nodeData: fullNodeData,
widgetIds: [widgetId],
nodeData: {
...fullNodeData,
widgets: [matchingWidget]
},
action: { widget, node }
}
]
})
})
function getDropIndicator(node: LGraphNode, id: WidgetId) {
function getDropIndicator(node: LGraphNode) {
if (node.type !== 'LoadImage') return undefined
const stringValue = extractWidgetStringValue(
widgetValueStore.getWidget(id)?.value
)
const stringValue = extractWidgetStringValue(node.widgets?.[0]?.value)
const { filename, subfolder, type } = stringValue
? parseImageWidgetValue(stringValue)
@@ -137,8 +120,8 @@ function getDropIndicator(node: LGraphNode, id: WidgetId) {
}
}
function nodeToNodeData(node: LGraphNode, id: WidgetId) {
const dropIndicator = getDropIndicator(node, id)
function nodeToNodeData(node: LGraphNode) {
const dropIndicator = getDropIndicator(node)
const nodeData = extractVueNodeData(node)
return {
@@ -165,13 +148,7 @@ defineExpose({ handleDragDrop })
</script>
<template>
<div
v-for="{
key,
persistedHeight,
nodeData,
widgetIds,
action
} in mappedSelections"
v-for="{ key, persistedHeight, nodeData, action } in mappedSelections"
:key
:class="
cn(
@@ -258,7 +235,6 @@ defineExpose({ handleDragDrop })
>
<NodeWidgets
:node-data
:widget-ids
:class="
cn(
'gap-y-3 rounded-lg py-1 [&_textarea]:resize-y **:[.col-span-2]:grid-cols-1',

View File

@@ -7,7 +7,7 @@ import Password from 'primevue/password'
import PrimeVue from 'primevue/config'
import ProgressSpinner from 'primevue/progressspinner'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, nextTick, ref } from 'vue'
import { computed, defineComponent, h, nextTick, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
@@ -38,29 +38,45 @@ vi.mock('@/stores/authStore', () => ({
}))
const mockTurnstileEnabled = ref(false)
const mockTurnstileEnforced = ref(false)
const mockTurnstileToken = ref('')
const mockTurnstileUnavailable = ref(false)
const mockReset = vi.fn()
let emitTurnstileToken: ((token: string) => void) | undefined
let emitTurnstileUnavailable: ((unavailable: boolean) => void) | undefined
// The reset-on-toggle behavior lives in useTurnstileGate itself (see
// useTurnstile.test.ts); this fake just wires token/unavailable through to
// `waiting` the same way so SignUpForm's submit gating can be exercised.
vi.mock('@/composables/auth/useTurnstile', () => ({
useTurnstile: () => ({
enabled: mockTurnstileEnabled,
enforced: mockTurnstileEnforced
enabled: mockTurnstileEnabled
}),
useTurnstileGate: () => ({
token: mockTurnstileToken,
unavailable: mockTurnstileUnavailable,
waiting: computed(
() =>
mockTurnstileEnabled.value &&
!mockTurnstileToken.value &&
!mockTurnstileUnavailable.value
)
})
}))
// Stub the real widget (which loads the external Turnstile script) with one that
// exposes a spyable reset() and lets a test drive the v-model token the way a
// solved challenge would.
// exposes a spyable reset() and lets a test drive the v-model token/unavailable
// the way a solved challenge (or a broken/slow widget) would.
vi.mock('./TurnstileWidget.vue', async () => {
const { defineComponent: defineMock } = await import('vue')
return {
default: defineMock({
name: 'TurnstileWidget',
emits: ['update:token'],
emits: ['update:token', 'update:unavailable'],
setup(_, { expose, emit }) {
expose({ reset: mockReset })
emitTurnstileToken = (token: string) => emit('update:token', token)
emitTurnstileUnavailable = (unavailable: boolean) =>
emit('update:unavailable', unavailable)
return () => null
}
})
@@ -92,9 +108,11 @@ describe('SignUpForm', () => {
beforeEach(() => {
mockLoadingRef.value = false
mockTurnstileEnabled.value = false
mockTurnstileEnforced.value = false
mockTurnstileToken.value = ''
mockTurnstileUnavailable.value = false
mockReset.mockClear()
emitTurnstileToken = undefined
emitTurnstileUnavailable = undefined
})
afterEach(() => {
@@ -211,43 +229,22 @@ describe('SignUpForm', () => {
})
})
describe('Turnstile token hygiene', () => {
it('clears the stale token when Turnstile becomes disabled', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = true
const { user } = renderComponent()
await fillValidSignup(user)
emitTurnstileToken!('stale-token')
await nextTick()
expect(
screen.getByRole('button', { name: signUpButton })
).not.toBeDisabled()
mockTurnstileEnabled.value = false
await nextTick()
// re-enable: the stale token must have been cleared so submit is blocked again
mockTurnstileEnabled.value = true
await nextTick()
expect(screen.getByRole('button', { name: signUpButton })).toBeDisabled()
})
})
// Regression coverage for the shadow-mode race: previously submit was only
// gated in 'enforce' mode, so most real signups in 'shadow' mode raced
// ahead of the async Cloudflare challenge and reached the backend with an
// empty token. Gating now depends only on whether the widget is enabled
// (shadow or enforce both render it), so both modes behave identically here.
describe('Turnstile submit gating', () => {
it('disables the submit button in enforce mode until a token is present', async () => {
it('disables the submit button until a token is present', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = true
renderComponent()
await nextTick()
expect(screen.getByRole('button', { name: signUpButton })).toBeDisabled()
})
it('does not emit submit in enforce mode while the token is empty', async () => {
it('does not emit submit while the token is empty', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = true
const onSubmit = vi.fn()
const { user } = renderComponent({ onSubmit })
await fillValidSignup(user)
@@ -257,9 +254,8 @@ describe('SignUpForm', () => {
expect(onSubmit).not.toHaveBeenCalled()
})
it('emits submit with the token in enforce mode once the challenge is solved', async () => {
it('emits submit with the token once the challenge is solved', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = true
const onSubmit = vi.fn()
const { user } = renderComponent({ onSubmit })
await fillValidSignup(user)
@@ -271,13 +267,14 @@ describe('SignUpForm', () => {
expect(onSubmit).toHaveBeenCalledWith(expectedValues, 'token-xyz')
})
it('emits submit without a token in shadow mode (never blocks)', async () => {
it('emits submit without a token once the widget reports itself unavailable (broken/slow load fallback)', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = false
const onSubmit = vi.fn()
const { user } = renderComponent({ onSubmit })
await fillValidSignup(user)
emitTurnstileUnavailable!(true)
await nextTick()
await user.click(screen.getByRole('button', { name: signUpButton }))
expect(onSubmit).toHaveBeenCalledWith(expectedValues, undefined)

View File

@@ -33,10 +33,11 @@
v-if="turnstileEnabled"
ref="turnstileWidget"
v-model:token="turnstileToken"
v-model:unavailable="turnstileUnavailable"
/>
<small
v-show="submitBlockedByTurnstile"
v-show="waitingForTurnstile"
id="comfy-org-sign-up-turnstile-hint"
role="status"
aria-live="polite"
@@ -51,11 +52,9 @@
v-else
type="submit"
class="mt-4 h-10 font-medium"
:disabled="!$form.valid || submitBlockedByTurnstile"
:disabled="!$form.valid || waitingForTurnstile"
:aria-describedby="
submitBlockedByTurnstile
? 'comfy-org-sign-up-turnstile-hint'
: undefined
waitingForTurnstile ? 'comfy-org-sign-up-turnstile-hint' : undefined
"
>
{{ t('auth.signup.signUpButton') }}
@@ -70,11 +69,11 @@ import { zodResolver } from '@primevue/forms/resolvers/zod'
import { useThrottleFn } from '@vueuse/core'
import InputText from 'primevue/inputtext'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { computed, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTurnstile } from '@/composables/auth/useTurnstile'
import { useTurnstile, useTurnstileGate } from '@/composables/auth/useTurnstile'
import { signUpSchema } from '@/schemas/signInSchema'
import type { SignUpData } from '@/schemas/signInSchema'
import { useAuthStore } from '@/stores/authStore'
@@ -86,25 +85,21 @@ const { t } = useI18n()
const authStore = useAuthStore()
const loading = computed(() => authStore.loading)
const { enabled: turnstileEnabled, enforced: turnstileEnforced } =
useTurnstile()
const turnstileToken = ref('')
const { enabled: turnstileEnabled } = useTurnstile()
const {
token: turnstileToken,
unavailable: turnstileUnavailable,
waiting: waitingForTurnstile
} = useTurnstileGate(turnstileEnabled)
const turnstileWidget =
useTemplateRef<InstanceType<typeof TurnstileWidget>>('turnstileWidget')
const submitBlockedByTurnstile = computed(
() => turnstileEnforced.value && !turnstileToken.value
)
watch(turnstileEnabled, (on) => {
if (!on) turnstileToken.value = ''
})
const emit = defineEmits<{
submit: [values: SignUpData, turnstileToken?: string]
}>()
const onSubmit = useThrottleFn((event: FormSubmitEvent) => {
if (event.valid && !submitBlockedByTurnstile.value) {
if (event.valid && !waitingForTurnstile.value) {
emit(
'submit',
event.values as SignUpData,

View File

@@ -261,4 +261,138 @@ describe('TurnstileWidget', () => {
expect(api.remove).toHaveBeenCalledWith('widget-id')
})
// A widget that never resolves (broken script, ad-blocker, CDN outage, or a
// hung challenge) must eventually tell the parent it cannot be relied on,
// so submission can fall back instead of blocking a legitimate signup
// forever.
describe('unavailable fallback', () => {
it('reports unavailable when the Turnstile script fails to load', async () => {
mockLoadTurnstile.mockRejectedValue(new Error('script failed'))
const { emitted } = renderWidget()
await flush()
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
})
it('reports unavailable on a challenge error', async () => {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
const { emitted } = renderWidget()
await flush()
options()!['error-callback']!()
await flush()
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
})
it('clears the unavailable fallback once a token is solved', async () => {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
const { emitted } = renderWidget()
await flush()
options()!['error-callback']!()
await flush()
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
options()!.callback!('token-abc')
await flush()
expect(emitted()['update:unavailable']?.at(-1)).toEqual([false])
})
it('falls back once the widget fails to resolve within the load timeout', async () => {
vi.useFakeTimers()
try {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
const { emitted } = renderWidget()
// Let the onMounted hook's `await loadTurnstile()` microtask settle
// and render() run, without yet advancing to the timeout itself.
await vi.advanceTimersByTimeAsync(0)
expect(options()).toBeDefined()
expect(emitted()['update:unavailable']).toBeUndefined()
await vi.advanceTimersByTimeAsync(9_000)
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
} finally {
vi.useRealTimers()
}
})
it('does not fall back once a token arrives before the load timeout', async () => {
vi.useFakeTimers()
try {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
const { emitted } = renderWidget()
await vi.advanceTimersByTimeAsync(0)
options()!.callback!('token-abc')
await vi.advanceTimersByTimeAsync(9_000)
expect(emitted()['update:unavailable']).toBeUndefined()
} finally {
vi.useRealTimers()
}
})
it('resets the widget to fetch a fresh challenge on token expiry', async () => {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
renderWidget()
await flush()
options()!.callback!('token-abc')
options()!['expired-callback']!()
await flush()
expect(api.reset).toHaveBeenCalledWith('widget-id')
})
it('falls back if a post-solve expiry is not followed by a fresh token within the load timeout', async () => {
vi.useFakeTimers()
try {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
const { emitted } = renderWidget()
await vi.advanceTimersByTimeAsync(0)
// Establish a solved, available widget: an initial error marks it
// unavailable, then solving a challenge clears that (the same
// transition the existing "clears the unavailable fallback" test
// verifies), so the expiry below is the only thing driving fallback.
options()!['error-callback']!()
options()!.callback!('token-abc')
expect(emitted()['update:unavailable']?.at(-1)).toEqual([false])
// The token later expires (e.g. tab backgrounded past its ~300s
// lifetime) without the widget itself erroring.
options()!['expired-callback']!()
await vi.advanceTimersByTimeAsync(0)
// A fresh challenge was requested, but nothing solves it before the
// re-armed load timeout elapses, so submission must eventually be
// unblocked rather than staying stuck forever.
expect(emitted()['update:unavailable']?.at(-1)).toEqual([false])
await vi.advanceTimersByTimeAsync(9_000)
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
} finally {
vi.useRealTimers()
}
})
})
})

View File

@@ -12,6 +12,7 @@
</template>
<script setup lang="ts">
import { useTimeoutFn } from '@vueuse/core'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -20,6 +21,14 @@ import { getTurnstileSiteKey } from '@/config/turnstile'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
const token = defineModel<string>('token', { default: '' })
/**
* Set true whenever the widget cannot be relied on to ever produce a token:
* the Cloudflare script failed to load, the rendered challenge errored out,
* or it simply hasn't resolved within `TURNSTILE_LOAD_TIMEOUT_MS`. The parent
* uses this to stop waiting on a token so a broken/slow widget (network
* issue, ad-blocker, CDN outage) can never permanently block signup.
*/
const unavailable = defineModel<boolean>('unavailable', { default: false })
const { t } = useI18n()
const colorPaletteStore = useColorPaletteStore()
@@ -28,6 +37,16 @@ const containerRef = ref<HTMLDivElement>()
const errorMessage = ref('')
let widgetId: string | undefined
/** How long to wait for the widget to resolve before falling back. */
const TURNSTILE_LOAD_TIMEOUT_MS = 9_000
const { start: armTimeout, stop: clearLoadTimeout } = useTimeoutFn(
() => {
unavailable.value = true
},
TURNSTILE_LOAD_TIMEOUT_MS,
{ immediate: false }
)
const clearToken = () => {
token.value = ''
}
@@ -46,12 +65,18 @@ const reset = () => {
errorMessage.value = ''
if (widgetId && window.turnstile) {
window.turnstile.reset(widgetId)
// A widget that renders can request a fresh challenge, so give it
// another chance before falling back again.
unavailable.value = false
armTimeout()
}
}
defineExpose({ reset })
onMounted(async () => {
armTimeout()
try {
const turnstile = await loadTurnstile()
if (!containerRef.value) return
@@ -64,23 +89,37 @@ onMounted(async () => {
sitekey: getTurnstileSiteKey(),
theme,
callback: (newToken: string) => {
clearLoadTimeout()
errorMessage.value = ''
unavailable.value = false
token.value = newToken
},
'expired-callback': () => {
clearToken()
errorMessage.value = t('auth.turnstile.expired')
if (widgetId && window.turnstile) {
window.turnstile.reset(widgetId)
// A solved token can expire on its own (e.g. the tab was
// backgrounded past the token's ~300s lifetime) without the widget
// ever erroring, so proactively request a fresh challenge and
// re-arm the load timeout in case it doesn't resolve in time.
armTimeout()
}
},
'error-callback': () => {
clearToken()
clearLoadTimeout()
console.warn('Turnstile challenge failed')
errorMessage.value = t('auth.turnstile.failed')
unavailable.value = true
if (widgetId && window.turnstile) window.turnstile.reset(widgetId)
}
})
} catch (error) {
clearLoadTimeout()
console.warn('Turnstile failed to load', error)
errorMessage.value = t('auth.turnstile.failed')
unavailable.value = true
}
})

View File

@@ -1,13 +1,9 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { getDomWidgetZIndex } from './domWidgetZIndex'
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
describe('getDomWidgetZIndex', () => {
it('follows graph node ordering when node.order is stale', () => {
const graph = new LGraph()

View File

@@ -13,8 +13,8 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import { isWidgetPromotedOnSubgraphNode } from '@/core/graph/subgraph/promotionUtils'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
@@ -256,10 +256,7 @@ function clearWidgetErrors(
source.sourceWidgetName,
source.sourceWidgetName,
value,
{
min: source.sourceWidget.options?.min,
max: source.sourceWidget.options?.max
}
options
)
}

View File

@@ -2,18 +2,14 @@ import { createTestingPinia } from '@pinia/testing'
import { render } from '@testing-library/vue'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useLinkStore } from '@/stores/linkStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
import WidgetItem from './WidgetItem.vue'
import { toLinkId } from '@/types/linkId'
import { toNodeId } from '@/types/nodeId'
const { mockGetInputSpecForWidget, StubWidgetComponent } = vi.hoisted(() => ({
@@ -208,51 +204,5 @@ describe('WidgetItem', () => {
expect(stub.value).toBe('model_a.safetensors')
})
it('passes null from widget state to the widget component', () => {
const id = widgetId('test-graph-id', toNodeId(1), 'ckpt_name')
const widget = createMockWidget({ widgetId: id, value: 'source value' })
useWidgetValueStore().registerWidget(id, {
type: 'combo',
value: null,
options: {}
})
const { container } = renderWidgetItem(widget)
const stub = getStubWidget(container)
expect(stub.value).toBe('null')
})
it('updates disabled options when the widget input is linked', async () => {
const inputs: INodeInputSlot[] = [
{
name: 'seed',
type: 'INT',
link: null,
boundingRect: [0, 0, 0, 0],
widget: { name: 'seed' }
}
]
const node = createMockNode(
fromAny<Partial<LGraphNode>, unknown>({ inputs })
)
const widget = createMockWidget({ name: 'seed', options: {} })
const { container } = renderWidgetItem(widget, node)
expect(getStubWidget(container).options.disabled).toBeUndefined()
useLinkStore().registerLink('test-graph-id', {
id: toLinkId(1),
originNodeId: toNodeId(2),
originSlot: 0,
targetNodeId: node.id,
targetSlot: 0,
type: 'INT'
})
await nextTick()
expect(getStubWidget(container).options.disabled).toBe(true)
})
})
})

View File

@@ -3,6 +3,8 @@ import { computed, customRef, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import { getControlWidget } from '@/composables/graph/useGraphNodeManager'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { st } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
@@ -13,18 +15,13 @@ import {
getComponent,
shouldExpand
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { useLinkStore } from '@/stores/linkStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import { getControlWidget } from '@/types/simplifiedWidget'
import type {
SimplifiedWidget,
WidgetValue as SimplifiedWidgetValue
} from '@/types/simplifiedWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { widgetId } from '@/types/widgetId'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@comfyorg/tailwind-utils'
@@ -64,7 +61,6 @@ const canvasStore = useCanvasStore()
const nodeDefStore = useNodeDefStore()
const widgetValueStore = useWidgetValueStore()
const favoritedWidgetsStore = useFavoritedWidgetsStore()
const linkStore = useLinkStore()
const isEditing = ref(false)
const widgetComponent = computed(() => {
@@ -73,14 +69,16 @@ const widgetComponent = computed(() => {
})
const isLinked = computed(() => {
const graphId = node.graph?.rootGraph.id
const slot = node.inputs?.findIndex((i) => i.widget?.name === widget.name)
if (!graphId || slot === undefined || slot < 0) return false
return linkStore.isInputSlotConnected(graphId, node.id, slot)
const safeWidget = useVueNodeLifecycle()
.nodeManager.value?.vueNodeData.get(node.id)
?.widgets?.find((w) => w.name === widget.name)
return safeWidget?.slotMetadata
? !!safeWidget.slotMetadata.linked
: !!node.inputs?.find((inp) => inp.widget?.name === widget.name)?.link
})
const simplifiedWidget = computed((): SimplifiedWidget => {
const graphId = node.graph?.rootGraph.id
const graphId = node.graph?.rootGraph?.id
const bareNodeId = stripGraphPrefix(node.id)
const widgetState = widget.widgetId
? useWidgetValueStore().getWidget(widget.widgetId)
@@ -95,9 +93,7 @@ const simplifiedWidget = computed((): SimplifiedWidget => {
return {
name: widgetName,
type: widgetType,
value: (widgetState
? widgetState.value
: widget.value) as SimplifiedWidgetValue,
value: widgetState?.value ?? widget.value,
label: widgetState?.label ?? widget.label,
options: { ...baseOptions, disabled },
spec: nodeDefStore.getInputSpecForWidget(node, widgetName),

View File

@@ -1,9 +1,11 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import {
isTurnstileEnabled,
normalizeTurnstileMode,
useTurnstile
useTurnstile,
useTurnstileGate
} from '@/composables/auth/useTurnstile'
import { getTurnstileSiteKey } from '@/config/turnstile'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
@@ -137,3 +139,63 @@ describe('useTurnstile', () => {
})
})
})
describe('useTurnstileGate', () => {
it('waits while enabled with no token yet', () => {
const { waiting } = useTurnstileGate(ref(true))
expect(waiting.value).toBe(true)
})
it('never waits while disabled', () => {
const { waiting } = useTurnstileGate(ref(false))
expect(waiting.value).toBe(false)
})
it('stops waiting once a token arrives', () => {
const { token, waiting } = useTurnstileGate(ref(true))
token.value = 'token-abc'
expect(waiting.value).toBe(false)
})
it('stops waiting once the widget reports itself unavailable', () => {
const { unavailable, waiting } = useTurnstileGate(ref(true))
unavailable.value = true
expect(waiting.value).toBe(false)
})
it('clears stale token/unavailable state when enabled turns off', async () => {
const enabled = ref(true)
const { token, unavailable } = useTurnstileGate(enabled)
token.value = 'stale-token'
unavailable.value = true
enabled.value = false
await nextTick()
expect(token.value).toBe('')
expect(unavailable.value).toBe(false)
})
// Regression coverage: the reset used to only run on the enabled->disabled
// transition, so state written while the widget was briefly disabled could
// survive into the next enabled widget instance.
it('clears stale token/unavailable state when enabled turns back on', async () => {
const enabled = ref(true)
const { token, unavailable } = useTurnstileGate(enabled)
enabled.value = false
await nextTick()
token.value = 'stale-token'
unavailable.value = true
enabled.value = true
await nextTick()
expect(token.value).toBe('')
expect(unavailable.value).toBe(false)
})
})

View File

@@ -1,4 +1,5 @@
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import type { Ref } from 'vue'
import { getTurnstileSiteKey } from '@/config/turnstile'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
@@ -42,3 +43,31 @@ export function useTurnstile() {
return { mode, siteKey, enabled, enforced }
}
/**
* Submit-gating state for the signup form's Turnstile widget: a token/
* unavailable pair, plus `waiting`, which is true while a real token is still
* needed. Waits in both shadow and enforce mode (`enabled`), not just
* `enforced`, so shadow mode's token can't race the async Cloudflare
* challenge; falls back open once the widget reports `unavailable` so a
* broken/slow load can never permanently block signup.
*
* `token`/`unavailable` reset on every `enabled` transition, in either
* direction, so state from a previous widget instance can never leak into a
* freshly (re-)rendered one.
*/
export function useTurnstileGate(enabled: Ref<boolean>) {
const token = ref('')
const unavailable = ref(false)
const waiting = computed(
() => enabled.value && !token.value && !unavailable.value
)
watch(enabled, () => {
token.value = ''
unavailable.value = false
})
return { token, unavailable, waiting }
}

View File

@@ -3,10 +3,7 @@ import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick, watch } from 'vue'
import {
extractVueNodeData,
useGraphNodeManager
} from '@/composables/graph/useGraphNodeManager'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { widgetId } from '@/types/widgetId'
import {
@@ -17,7 +14,6 @@ import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { linkedWidgetedInputs } from '@/renderer/extensions/vueNodes/utils/nodeDataUtils'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
@@ -66,29 +62,15 @@ describe('Node Reactivity', () => {
expect(onValueChange).toHaveBeenCalledTimes(1)
})
it('does not re-wrap node.widgets on repeated extraction', () => {
const { node } = createTestGraph()
const widgetsGetter = () =>
Object.getOwnPropertyDescriptor(node, 'widgets')?.get
extractVueNodeData(node)
const firstGetter = widgetsGetter()
extractVueNodeData(node)
extractVueNodeData(node)
expect(widgetsGetter()).toBe(firstGetter)
})
it('widget values remain reactive after a connection is made', async () => {
const { node, graph } = createTestGraph()
const store = useWidgetValueStore()
const onValueChange = vi.fn()
const upstream = new LGraphNode('upstream')
upstream.addOutput('out', 'INT')
graph.add(upstream)
upstream.connect(0, node, 0)
graph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT
})
await nextTick()
const state = store.getWidget(widgetId(graph.id, node.id, 'testnum'))
@@ -105,7 +87,7 @@ describe('Node Reactivity', () => {
})
})
describe('Widget input link reactivity', () => {
describe('Widget slotMetadata reactivity on link disconnect', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
@@ -114,8 +96,10 @@ describe('Widget input link reactivity', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
// Add a widget and an associated input slot (simulates "widget converted to input")
node.addWidget('string', 'prompt', 'hello', () => undefined, {})
const input = node.addInput('prompt', 'STRING')
// Associate the input slot with the widget (as widgetInputs extension does)
input.widget = { name: 'prompt' }
graph.add(node)
@@ -128,14 +112,81 @@ describe('Widget input link reactivity', () => {
return { graph, node, upstream, linkId: link.id }
}
it('exposes linked widget input slots through Vue node inputs', () => {
it('sets slotMetadata.linked to true when input has a link', () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(nodeData?.inputs?.[0]?.widget?.name).toBe('prompt')
expect(nodeData?.inputs?.[0]?.link).not.toBeNull()
expect(widgetData?.slotMetadata).toBeDefined()
expect(widgetData?.slotMetadata?.linked).toBe(true)
})
it('updates slotMetadata.linked to false after link disconnect event', async () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
// Verify initially linked
expect(widgetData?.slotMetadata?.linked).toBe(true)
// Simulate link disconnection (as LiteGraph does before firing the event)
node.inputs[0].link = null
// Fire the trigger event that LiteGraph fires on disconnect
graph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
slotIndex: 0,
connected: false,
linkId: 42
})
await nextTick()
// slotMetadata.linked should now be false
expect(widgetData?.slotMetadata?.linked).toBe(false)
})
it('reactively updates disabled state in a derived computed after disconnect', async () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)!
// Mimic what processedWidgets does in NodeWidgets.vue:
// derive disabled from slotMetadata.linked
const derivedDisabled = computed(() => {
const widgets = nodeData.widgets ?? []
const widget = widgets.find((w) => w.name === 'prompt')
return widget?.slotMetadata?.linked ? true : false
})
// Initially linked → disabled
expect(derivedDisabled.value).toBe(true)
// Track changes
const onChange = vi.fn()
watch(derivedDisabled, onChange)
// Simulate disconnect
node.inputs[0].link = null
graph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
slotIndex: 0,
connected: false,
linkId: 42
})
await nextTick()
// The derived computed should now return false
expect(derivedDisabled.value).toBe(false)
expect(onChange).toHaveBeenCalledTimes(1)
})
it('marks a widget input slot as linked when connected to a SubgraphInput', () => {
@@ -154,14 +205,15 @@ describe('Widget input link reactivity', () => {
const { vueNodeData } = useGraphNodeManager(subgraph)
const nodeData = vueNodeData.get(node.id)
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(nodeData?.inputs?.[0]?.link).not.toBeNull()
expect(
linkedWidgetedInputs(nodeData!, subgraph.rootGraph.id).map((s) => s.name)
).toEqual(['prompt'])
expect(widgetData?.slotMetadata?.linked).toBe(true)
})
it('registers promoted widget render state separately from value state', () => {
it('names promoted widgets after the subgraph input slot and exposes the interior source name', () => {
// Subgraph input named "value" promotes an interior "prompt" widget. The
// projected widget's name is the input slot name "value"; the interior
// source widget name "prompt" is carried separately for backend lookups.
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'STRING' }]
})
@@ -177,21 +229,39 @@ describe('Widget input link reactivity', () => {
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
useGraphNodeManager(graph)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(subgraphNode.id)
const id = widgetId(graph.id, subgraphNode.id, 'value')
const store = useWidgetValueStore()
const valueState = store.getWidget(id)
const renderState = store.getWidgetRenderState(id)
const widgetData = nodeData?.widgets?.find((w) => w.name === 'value')
expect(widgetData).toBeDefined()
expect(widgetData?.sourceWidgetName).toBe('prompt')
expect(widgetData?.slotMetadata).toBeDefined()
})
expect(valueState?.name).toBe('value')
expect(valueState?.value).toBe('hello')
expect(renderState).toMatchObject({
hasLayoutSize: false,
isDOMWidget: false
it('clears stale slotMetadata when input no longer matches widget', async () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)!
const widgetData = nodeData.widgets!.find((w) => w.name === 'prompt')!
expect(widgetData.slotMetadata?.linked).toBe(true)
node.inputs[0].name = 'other'
node.inputs[0].widget = { name: 'other' }
node.inputs[0].link = null
graph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
slotIndex: 0,
connected: false,
linkId: 42
})
expect(renderState).not.toHaveProperty('sourceWidgetName')
expect(subgraphNode.inputs[0].widget?.name).toBe('value')
await nextTick()
expect(widgetData.slotMetadata).toBeUndefined()
})
})
@@ -298,13 +368,15 @@ describe('Nested promoted widget mapping', () => {
const graph = subgraphNodeB.graph as LGraph
graph.add(subgraphNodeB)
useGraphNodeManager(graph)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(subgraphNodeB.id)
const mappedWidget = nodeData?.widgets?.[0]
const id = widgetId(graph.id, subgraphNodeB.id, 'b_input')
const state = useWidgetValueStore().getWidget(id)
expect(state?.type).toBe('combo')
expect(subgraphNodeB.widgets[0]?.widgetId).toBe(id)
expect(mappedWidget).toBeDefined()
expect(mappedWidget?.type).toBe('combo')
expect(mappedWidget?.widgetId).toBe(
widgetId(graph.id, subgraphNodeB.id, 'b_input')
)
})
it('preserves distinct store identity for duplicate-named promoted widgets', () => {
@@ -333,23 +405,27 @@ describe('Nested promoted widget mapping', () => {
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
useGraphNodeManager(graph)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(subgraphNode.id)
const widgets = nodeData?.widgets
const ids = subgraphNode.widgets.map((widget) => widget.widgetId)
expect(ids).toStrictEqual([
widgetId(graph.id, subgraphNode.id, 'first_seed'),
expect(widgets).toHaveLength(2)
expect(widgets?.[0]?.widgetId).toBe(
widgetId(graph.id, subgraphNode.id, 'first_seed')
)
expect(widgets?.[1]?.widgetId).toBe(
widgetId(graph.id, subgraphNode.id, 'second_seed')
])
expect(ids[0]).not.toBe(ids[1])
)
expect(widgets?.[0]?.widgetId).not.toBe(widgets?.[1]?.widgetId)
})
})
describe('Promoted widget render state', () => {
describe('Promoted widget sourceExecutionId', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('registers plain render metadata for promoted widgets', () => {
it('sets sourceExecutionId to the interior node execution ID for promoted widgets', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'ckpt_input', type: '*' }]
})
@@ -375,21 +451,22 @@ describe('Promoted widget render state', () => {
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
useGraphNodeManager(graph)
const renderState = useWidgetValueStore().getWidgetRenderState(
widgetId(graph.id, subgraphNode.id, 'ckpt_input')
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(subgraphNode.id)
const promotedWidget = nodeData?.widgets?.find(
(w) => w.name === 'ckpt_input'
)
expect(renderState).toMatchObject({
hasLayoutSize: false,
isDOMWidget: false
})
expect(renderState).not.toHaveProperty('sourceWidgetName')
expect(renderState).not.toHaveProperty('sourceExecutionId')
expect(promotedWidget).toBeDefined()
expect(promotedWidget?.sourceWidgetName).toBe('ckpt_name')
// The interior node is inside subgraphNode (id=65),
// so its execution ID should be "65:<interiorNodeId>"
expect(promotedWidget?.sourceExecutionId).toBe(
`${subgraphNode.id}:${interiorNode.id}`
)
})
it('registers plain render metadata for non-promoted widgets', () => {
it('does not set sourceExecutionId for non-promoted widgets', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addWidget('number', 'steps', 20, () => undefined, {})
@@ -397,14 +474,12 @@ describe('Promoted widget render state', () => {
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
useGraphNodeManager(graph)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)
const widget = nodeData?.widgets?.find((w) => w.name === 'steps')
const renderState = useWidgetValueStore().getWidgetRenderState(
widgetId(graph.id, node.id, 'steps')
)
expect(renderState).toBeDefined()
expect(renderState).not.toHaveProperty('sourceExecutionId')
expect(widget).toBeDefined()
expect(widget?.sourceExecutionId).toBeUndefined()
})
})

View File

@@ -1,6 +1,14 @@
/**
* Vue node lifecycle management for LiteGraph integration
* Provides event-driven reactivity with performance optimizations
*/
import { reactiveComputed } from '@vueuse/core'
import cloneDeep from 'es-toolkit/compat/cloneDeep'
import { reactive, shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { promotedInputWidgets } from '@/core/graph/subgraph/promotedInputWidget'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import type {
INodeInputSlot,
INodeOutputSlot
@@ -11,6 +19,16 @@ import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import { toNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isDOMWidget } from '@/scripts/domWidget'
import { IS_CONTROL_WIDGET } from '@/scripts/widgets'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
import { normalizeControlOption } from '@/types/simplifiedWidget'
import { getWidgetIdForNode } from '@/utils/litegraphUtil'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import type { WidgetId } from '@/types/widgetId'
import type {
LGraph,
@@ -18,14 +36,69 @@ import type {
LGraphNode,
LGraphTriggerAction,
LGraphTriggerEvent,
LGraphTriggerParam
LGraphTriggerParam,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { app } from '@/scripts/app'
export interface WidgetSlotMetadata {
index: number
linked: boolean
originNodeId?: NodeId
originOutputName?: string
type: string
}
type Badges = (LGraphBadge | (() => LGraphBadge))[]
const reactiveArrayNodes = new WeakSet<LGraphNode>()
/**
* Minimal render-specific widget data extracted from LiteGraph widgets.
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
*/
export interface SafeWidgetData {
widgetId?: WidgetId
nodeId?: NodeId
name: string
type: string
/** Callback to invoke when widget value changes (wraps LiteGraph callback + triggerDraw) */
callback?: ((value: unknown) => void) | undefined
/** Control widget for seed randomization/increment/decrement */
controlWidget?: SafeControlWidget
/** Whether widget has custom layout size computation */
hasLayoutSize?: boolean
/** Whether widget is a DOM widget */
isDOMWidget?: boolean
/**
* Widget options needed for render decisions.
* Note: Most metadata should be accessed via widgetValueStore.getWidget().
*/
options?: {
canvasOnly?: boolean
advanced?: boolean
hidden?: boolean
read_only?: boolean
values?: unknown
}
/** Input specification from node definition */
spec?: InputSpec
/** Input slot metadata (index and link status) */
slotMetadata?: WidgetSlotMetadata
/**
* Execution ID of the interior node that owns the source widget.
* Only set for promoted widgets where the source node differs from the host
* subgraph node. Retained for source-scoped validation errors.
*/
sourceExecutionId?: NodeExecutionId
/**
* Interior source widget name. Only set for promoted widgets, where `name` is
* the host input slot name and the source widget name can differ.
*/
sourceWidgetName?: string
/** Tooltip text from the resolved widget. */
tooltip?: string
}
export interface VueNodeData {
executing: boolean
@@ -51,23 +124,251 @@ export interface VueNodeData {
showAdvanced?: boolean
subgraphId?: string | null
titleMode?: TitleMode
widgets?: SafeWidgetData[]
}
export interface GraphNodeManager {
// Reactive state - safe data extracted from LiteGraph nodes
vueNodeData: ReadonlyMap<NodeId, VueNodeData>
// Access to original LiteGraph nodes (non-reactive)
getNode(id: NodeId): LGraphNode | undefined
// Lifecycle methods
cleanup(): void
}
function makeReactiveNodeArrays(node: LGraphNode): {
inputs: INodeInputSlot[]
outputs: INodeOutputSlot[]
} {
// Wrapping is one-shot: re-running would stack another getter layer per call.
if (reactiveArrayNodes.has(node)) {
return { inputs: node.inputs ?? [], outputs: node.outputs ?? [] }
export function getControlWidget(
widget: IBaseWidget
): SafeControlWidget | undefined {
const cagWidget = widget.linkedWidgets?.find((w) => w[IS_CONTROL_WIDGET])
if (!cagWidget) return
return {
value: normalizeControlOption(cagWidget.value),
update: (value) => (cagWidget.value = normalizeControlOption(value))
}
reactiveArrayNodes.add(node)
}
interface SharedWidgetEnhancements {
controlWidget?: SafeControlWidget
spec?: InputSpec
}
function getSharedWidgetEnhancements(
node: LGraphNode,
widget: IBaseWidget
): SharedWidgetEnhancements {
const nodeDefStore = useNodeDefStore()
return {
controlWidget: getControlWidget(widget),
spec: nodeDefStore.getInputSpecForWidget(node, widget.name)
}
}
/**
* Validates that a value is a valid WidgetValue type
*/
function normalizeWidgetValue(value: unknown): WidgetValue {
if (value === null || value === undefined || value === void 0) {
return undefined
}
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
return value
}
if (typeof value === 'object') {
// Check if it's a File array
if (
Array.isArray(value) &&
value.length > 0 &&
value.every((item): item is File => item instanceof File)
) {
return value
}
// Otherwise it's a generic object
return value
}
// If none of the above, return undefined
console.warn(`Invalid widget value type: ${typeof value}`, value)
return undefined
}
function extractWidgetDisplayOptions(
widget: IBaseWidget
): SafeWidgetData['options'] {
if (!widget.options) return undefined
return {
canvasOnly: widget.options.canvasOnly,
advanced: widget.options?.advanced ?? widget.advanced,
hidden: widget.options.hidden,
read_only: widget.options.read_only
}
}
function isDOMBackedWidget(widget: IBaseWidget): boolean {
return (
('element' in widget && !!widget.element) ||
('component' in widget && !!widget.component)
)
}
interface PromotedWidgetMetadata {
controlWidget?: SafeControlWidget
isDOMWidget: boolean
sourceExecutionId?: NodeExecutionId
sourceWidgetName?: string
}
/**
* Resolves the interior source of a promoted subgraph input to derive the
* metadata that backend lookups key by (execution ID, interior widget name)
* plus the source widget's control + DOM nature. Also seeds host widget state
* if it is somehow missing. Returns undefined when the widget is not promoted.
*/
function resolvePromotedMetadata(
node: SubgraphNode,
widget: IBaseWidget
): PromotedWidgetMetadata | undefined {
const source = resolvePromotedWidgetSource(app.rootGraph, node, widget)
if (!source) return undefined
ensurePromotedHostWidgetState(
source.input.widgetId,
source.input,
source.sourceWidget
)
return {
controlWidget: getControlWidget(source.sourceWidget),
isDOMWidget: isDOMBackedWidget(source.sourceWidget),
sourceExecutionId: source.sourceExecutionId,
sourceWidgetName: source.sourceWidgetName
}
}
function safeWidgetMapper(
node: LGraphNode,
slotMetadata: Map<string, WidgetSlotMetadata>
): (widget: IBaseWidget) => SafeWidgetData {
const duplicateIndexByKey = new Map<string, number>()
return function (widget) {
try {
const duplicateKey = `${widget.name}:${widget.type}`
const duplicateIndex = duplicateIndexByKey.get(duplicateKey) ?? 0
duplicateIndexByKey.set(duplicateKey, duplicateIndex + 1)
const slotInfo = slotMetadata.get(widget.name)
// Wrapper callback specific to Nodes 2.0 rendering
const callback = (v: unknown) => {
const value = normalizeWidgetValue(v)
widget.value = value ?? undefined
// Match litegraph callback signature: (value, canvas, node, pos, event)
// Some extensions (e.g., Impact Pack) expect node as the 3rd parameter
widget.callback?.(value, app.canvas, node)
// Trigger redraw for all legacy widgets on this node (e.g., mask preview)
// This ensures widgets that depend on other widget values get updated
node.widgets?.forEach((w) => w.triggerDraw?.())
}
const promoted = node.isSubgraphNode()
? resolvePromotedMetadata(node, widget)
: undefined
return {
widgetId: getWidgetIdForNode(node, widget, duplicateIndex),
name: widget.name,
type: widget.type,
...getSharedWidgetEnhancements(node, widget),
...(promoted?.controlWidget && {
controlWidget: promoted.controlWidget
}),
callback,
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
isDOMWidget: promoted?.isDOMWidget ?? isDOMWidget(widget),
options: extractWidgetDisplayOptions(widget),
slotMetadata: slotInfo,
sourceExecutionId: promoted?.sourceExecutionId,
sourceWidgetName: promoted?.sourceWidgetName,
tooltip: widget.tooltip
}
} catch (error) {
console.warn(
'[safeWidgetMapper] Failed to map widget:',
widget.name,
error
)
return {
name: widget.name || 'unknown',
type: widget.type || 'text'
}
}
}
}
function ensurePromotedHostWidgetState(
id: WidgetId,
input: INodeInputSlot,
sourceWidget: IBaseWidget | undefined
): void {
if (!sourceWidget) return
const store = useWidgetValueStore()
if (store.getWidget(id)) return
store.registerWidget(id, {
type: sourceWidget.type,
value: sourceWidget.value,
options: cloneDeep(sourceWidget.options ?? {}),
label: input.label ?? input.name,
serialize: sourceWidget.serialize,
disabled: sourceWidget.disabled
})
}
function buildSlotMetadata(
inputs: INodeInputSlot[] | undefined,
graphRef: LGraph | null | undefined
): Map<string, WidgetSlotMetadata> {
const metadata = new Map<string, WidgetSlotMetadata>()
inputs?.forEach((input, index) => {
let originNodeId: NodeId | undefined
let originOutputName: string | undefined
if (input.link != null && graphRef) {
const link = graphRef.getLink(input.link)
const originNode = link ? graphRef.getNodeById(link.origin_id) : null
if (link && originNode) {
originNodeId = link.origin_id
originOutputName = originNode.outputs?.[link.origin_slot]?.name
}
}
const slotInfo: WidgetSlotMetadata = {
index,
linked: input.link != null,
originNodeId,
originOutputName,
type: String(input.type)
}
if (input.name) metadata.set(input.name, slotInfo)
if (input.widget?.name) metadata.set(input.widget.name, slotInfo)
})
return metadata
}
// Extract safe data from LiteGraph node for Vue consumption
export function extractVueNodeData(node: LGraphNode): VueNodeData {
// Determine subgraph ID - null for root graph, string for subgraphs
const subgraphId =
node.graph && 'id' in node.graph && node.graph !== node.graph.rootGraph
? String(node.graph.id)
: null
// Extract safe widget data
const slotMetadata = new Map<string, WidgetSlotMetadata>()
const existingWidgetsDescriptor = Object.getOwnPropertyDescriptor(
node,
@@ -75,6 +376,8 @@ function makeReactiveNodeArrays(node: LGraphNode): {
)
const reactiveWidgets = shallowReactive<IBaseWidget[]>(node.widgets ?? [])
if (existingWidgetsDescriptor?.get) {
// Node has a custom widgets getter (e.g. SubgraphNode's synthetic getter).
// Preserve it but sync results into a reactive array for Vue.
const originalGetter = existingWidgetsDescriptor.get
Object.defineProperty(node, 'widgets', {
get() {
@@ -103,7 +406,6 @@ function makeReactiveNodeArrays(node: LGraphNode): {
enumerable: true
})
}
const reactiveInputs = shallowReactive<INodeInputSlot[]>(node.inputs ?? [])
Object.defineProperty(node, 'inputs', {
get() {
@@ -115,7 +417,6 @@ function makeReactiveNodeArrays(node: LGraphNode): {
configurable: true,
enumerable: true
})
const reactiveOutputs = shallowReactive<INodeOutputSlot[]>(node.outputs ?? [])
Object.defineProperty(node, 'outputs', {
get() {
@@ -128,16 +429,19 @@ function makeReactiveNodeArrays(node: LGraphNode): {
enumerable: true
})
return { inputs: reactiveInputs, outputs: reactiveOutputs }
}
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
const freshMetadata = buildSlotMetadata(node.inputs, node.graph)
slotMetadata.clear()
for (const [key, value] of freshMetadata) {
slotMetadata.set(key, value)
}
export function extractVueNodeData(node: LGraphNode): VueNodeData {
const subgraphId =
node.graph && 'id' in node.graph && node.graph !== node.graph.rootGraph
? String(node.graph.id)
: null
const widgets = node.isSubgraphNode()
? promotedInputWidgets(node)
: (node.widgets ?? [])
return widgets.map(safeWidgetMapper(node, slotMetadata))
})
const { inputs, outputs } = makeReactiveNodeArrays(node)
const nodeType =
node.type ||
node.constructor?.comfyClass ||
@@ -145,6 +449,9 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
node.constructor?.name ||
'Unknown'
const apiNode = node.constructor?.nodeData?.api_node ?? false
const badges = node.badges
return {
id: node.id,
title: typeof node.title === 'string' ? node.title : '',
@@ -152,13 +459,14 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
mode: node.mode || 0,
titleMode: node.title_mode,
selected: node.selected || false,
executing: false,
executing: false, // Will be updated separately based on execution state
subgraphId,
apiNode: node.constructor?.nodeData?.api_node ?? false,
badges: node.badges,
apiNode,
badges,
hasErrors: !!node.has_errors,
inputs,
outputs,
widgets: safeWidgets,
inputs: reactiveInputs,
outputs: reactiveOutputs,
flags: node.flags ? { ...node.flags } : undefined,
color: node.color || undefined,
bgcolor: node.bgcolor || undefined,
@@ -169,17 +477,39 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
}
export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Get layout mutations composable
const { createNode, deleteNode, setSource } = useLayoutMutations()
// Safe reactive data extracted from LiteGraph nodes
const vueNodeData = reactive(new Map<NodeId, VueNodeData>())
// Non-reactive storage for original LiteGraph nodes
const nodeRefs = new Map<NodeId, LGraphNode>()
const getNode = (id: NodeId): LGraphNode | undefined => nodeRefs.get(id)
const refreshNodeSlots = (nodeId: NodeId) => {
const nodeRef = nodeRefs.get(nodeId)
const currentData = vueNodeData.get(nodeId)
if (!nodeRef || !currentData) return
const slotMetadata = buildSlotMetadata(nodeRef.inputs, graph)
// Update only widgets with new slot metadata, keeping other widget data intact
for (const widget of currentData.widgets ?? []) {
widget.slotMetadata = slotMetadata.get(widget.name)
}
}
// Get access to original LiteGraph node (non-reactive)
const getNode = (id: NodeId): LGraphNode | undefined => {
return nodeRefs.get(id)
}
const syncWithGraph = () => {
if (!graph?._nodes) return
const currentNodes = new Set(graph._nodes.map((n) => n.id))
// Remove deleted nodes
for (const id of Array.from(vueNodeData.keys())) {
if (!currentNodes.has(id)) {
nodeRefs.delete(id)
@@ -187,49 +517,76 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
}
}
// Add/update existing nodes
graph._nodes.forEach((node) => {
const id = node.id
// Store non-reactive reference
nodeRefs.set(id, node)
// Extract and store safe data for Vue
vueNodeData.set(id, extractVueNodeData(node))
})
}
/**
* Handles node addition to the graph - sets up Vue state and spatial indexing
* Defers position extraction until after potential configure() calls
*/
const handleNodeAdded = (
node: LGraphNode,
originalCallback?: (node: LGraphNode) => void
) => {
const id = node.id
// Store non-reactive reference to original node
nodeRefs.set(id, node)
// Extract initial data for Vue (may be incomplete during graph configure)
vueNodeData.set(id, extractVueNodeData(node))
const initializeVueNodeLayout = () => {
// Check if the node was removed mid-sequence
if (!nodeRefs.has(id)) return
// Extract actual positions after configure() has potentially updated them
const nodePosition = { x: node.pos[0], y: node.pos[1] }
const nodeSize = { width: node.size[0], height: node.size[1] }
// Skip layout creation if it already exists
// (e.g. in-place node replacement where the old node's layout is reused for the new node with the same ID).
const existingLayout = layoutStore.getNodeLayoutRef(id).value
if (existingLayout) return
// Add node to layout store with final positions
setSource(LayoutSource.Canvas)
void createNode(id, {
position: { x: node.pos[0], y: node.pos[1] },
size: { width: node.size[0], height: node.size[1] },
position: nodePosition,
size: nodeSize,
zIndex: node.order || 0,
visible: true
})
}
// Check if we're in the middle of configuring the graph (workflow loading)
if (window.app?.configuringGraph) {
// During workflow loading - defer layout initialization until configure completes
// Chain our callback with any existing onAfterGraphConfigured callback
node.onAfterGraphConfigured = useChainCallback(
node.onAfterGraphConfigured,
() => {
// Re-extract data now that configure() has populated title/slots/widgets/etc.
vueNodeData.set(id, extractVueNodeData(node))
initializeVueNodeLayout()
}
)
} else {
// Not during workflow loading - initialize layout immediately
// This handles individual node additions during normal operation
initializeVueNodeLayout()
}
// Call original callback if provided
if (originalCallback) {
void originalCallback(node)
}
@@ -246,12 +603,16 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
) => {
const id = node.id
// Remove node from layout store
setSource(LayoutSource.Canvas)
deleteNode(id)
void deleteNode(id)
dropNodeReferences(id)
originalCallback?.(node)
}
/**
* Creates cleanup function for event listeners and state
*/
const createCleanupFunction = (
originalOnNodeAdded: ((node: LGraphNode) => void) | undefined,
originalOnNodeRemoved: ((node: LGraphNode) => void) | undefined,
@@ -259,6 +620,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
beforeNodeRemovedListener: (e: CustomEvent<{ node: LGraphNode }>) => void
) => {
return () => {
// Restore original callbacks
graph.onNodeAdded = originalOnNodeAdded || undefined
graph.onNodeRemoved = originalOnNodeRemoved || undefined
graph.onTrigger = originalOnTrigger || undefined
@@ -268,16 +630,19 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
beforeNodeRemovedListener
)
// Clear all state maps
nodeRefs.clear()
vueNodeData.clear()
}
}
const setupEventListeners = (): (() => void) => {
// Store original callbacks
const originalOnNodeAdded = graph.onNodeAdded
const originalOnNodeRemoved = graph.onNodeRemoved
const originalOnTrigger = graph.onTrigger
// Set up graph event handlers
graph.onNodeAdded = (node: LGraphNode) => {
handleNodeAdded(node, originalOnNodeAdded)
}
@@ -395,17 +760,29 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
}
}
},
'node:slot-errors:changed': (slotErrorsEvent) => {
refreshNodeSlots(toNodeId(slotErrorsEvent.nodeId))
},
'node:slot-links:changed': (slotLinksEvent) => {
if (slotLinksEvent.slotType === NodeSlotType.INPUT) {
refreshNodeSlots(toNodeId(slotLinksEvent.nodeId))
}
},
'node:slot-label:changed': (slotLabelEvent) => {
const nodeId = toNodeId(slotLabelEvent.nodeId)
const nodeRef = nodeRefs.get(nodeId)
if (!nodeRef) return
// Force shallowReactive to detect the deep property change
// by re-assigning the affected array through the defineProperty setter.
if (slotLabelEvent.slotType !== NodeSlotType.OUTPUT && nodeRef.inputs) {
nodeRef.inputs = [...nodeRef.inputs]
}
if (slotLabelEvent.slotType !== NodeSlotType.INPUT && nodeRef.outputs) {
nodeRef.outputs = [...nodeRef.outputs]
}
// Re-extract widget data so the label reflects the rename
vueNodeData.set(nodeId, extractVueNodeData(nodeRef))
}
}
@@ -414,14 +791,22 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
case 'node:property:changed':
triggerHandlers['node:property:changed'](event)
break
case 'node:slot-errors:changed':
triggerHandlers['node:slot-errors:changed'](event)
break
case 'node:slot-links:changed':
triggerHandlers['node:slot-links:changed'](event)
break
case 'node:slot-label:changed':
triggerHandlers['node:slot-label:changed'](event)
break
}
// Chain to original handler
originalOnTrigger?.(event)
}
// Initialize state
syncWithGraph()
return createCleanupFunction(
@@ -432,8 +817,10 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
)
}
// Set up event listeners immediately
const cleanup = setupEventListeners()
// Process any existing nodes after event listeners are set up
if (graph._nodes && graph._nodes.length > 0) {
graph._nodes.forEach((node: LGraphNode) => {
if (graph.onNodeAdded) {

View File

@@ -10,6 +10,7 @@ import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMuta
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
import { app as comfyApp } from '@/scripts/app'
import { UNASSIGNED_NODE_ID } from '@/types/nodeId'
function useVueNodeLifecycleIndividual() {
const canvasStore = useCanvasStore()
@@ -38,7 +39,25 @@ function useVueNodeLifecycleIndividual() {
// Seed reroutes into the Layout Store so hit-testing uses the new path
for (const reroute of activeGraph.reroutes.values()) {
const [x, y] = reroute.pos
layoutMutations.createReroute(reroute.id, { x, y })
const parent = reroute.parentId ?? undefined
const linkIds = Array.from(reroute.linkIds)
layoutMutations.createReroute(reroute.id, { x, y }, parent, linkIds)
}
// Seed existing links into the Layout Store (topology only)
for (const link of activeGraph._links.values()) {
if (
link.origin_id === UNASSIGNED_NODE_ID ||
link.target_id === UNASSIGNED_NODE_ID
)
continue
layoutMutations.createLink(
link.id,
link.origin_id,
link.origin_slot,
link.target_id,
link.target_slot
)
}
// Start sync AFTER seeding so bootstrap operations don't trigger

View File

@@ -21,7 +21,7 @@ export function useUpstreamValue<T>(
return computed(() => {
const upstream = getLinkedUpstream()
if (!upstream) return undefined
const graphId = canvasStore.rootGraphId
const graphId = canvasStore.canvas?.graph?.rootGraph.id
if (!graphId) return undefined
const widgets = widgetValueStore.getNodeWidgets(graphId, upstream.nodeId)
return extractValue(widgets, upstream.outputName)

View File

@@ -145,13 +145,6 @@ function applySubgraphInputOrder(
})
reorderSubgraphInputs(subgraphNode, orderedIndices)
useWidgetValueStore().setNodeWidgetOrder(
subgraphNode.rootGraph.id,
subgraphNode.id,
subgraphNode.inputs.flatMap((input) =>
input.widgetId ? [input.widgetId] : []
)
)
for (const [newIndex, oldIndex] of orderedIndices.entries()) {
const value = widgetValues[oldIndex]
@@ -288,26 +281,22 @@ function seedNestedPromotedInputState(
)
if (!hostInput || hostInput.widgetId) return
const store = useWidgetValueStore()
const sourceState = store.getWidget(sourceSlot.widgetId)
const sourceState = useWidgetValueStore().getWidget(sourceSlot.widgetId)
if (!sourceState) return
const id = widgetId(subgraphNode.rootGraph.id, subgraphNode.id, inputName)
hostInput.widget ??= { name: inputName }
hostInput.widget.name = inputName
hostInput.widgetId = id
store.registerWidget(
id,
{
type: sourceState.type,
value: sourceState.value,
options: cloneDeep(sourceState.options ?? {}),
label: hostInput.label ?? sourceSlot.label ?? inputName,
serialize: sourceState.serialize,
disabled: sourceState.disabled
},
store.getWidgetRenderState(sourceSlot.widgetId) ?? {}
)
useWidgetValueStore().registerWidget(id, {
type: sourceState.type,
value: sourceState.value,
options: cloneDeep(sourceState.options ?? {}),
label: hostInput.label ?? sourceSlot.label ?? inputName,
serialize: sourceState.serialize,
disabled: sourceState.disabled,
isDOMWidget: sourceState.isDOMWidget
})
}
function promotePreviewViaExposure(

View File

@@ -11,10 +11,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LLink } from '@/lib/litegraph/src/LLink'
import { commonType } from '@/lib/litegraph/src/utils/type'
import {
getWidgetIds,
resolveNodeRootGraphId
} from '@/lib/litegraph/src/utils/widget'
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/utils/widget'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { ComboInputSpec, InputSpec } from '@/schemas/nodeDefSchema'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
@@ -51,16 +48,6 @@ type AutogrowNode = LGraphNode &
}
}
function syncNodeWidgetOrder(node: LGraphNode) {
const graphId = resolveNodeRootGraphId(node)
if (!graphId || !node.widgets) return
useWidgetValueStore().setNodeWidgetOrder(
graphId,
node.id,
getWidgetIds(node.widgets)
)
}
function ensureWidgetForInput(node: LGraphNode, input: INodeInputSlot) {
node.widgets ??= []
const { widget } = input
@@ -118,10 +105,7 @@ function dynamicComboWidget(
if (widget.widgetId) deleteWidget(widget.widgetId)
}
if (!newSpec) {
syncNodeWidgetOrder(node)
return
}
if (!newSpec) return
const insertionPoint = node.widgets.findIndex((w) => w === widget) + 1
const startingLength = node.widgets.length
@@ -156,7 +140,6 @@ function dynamicComboWidget(
node.inputs.findIndex((i) => i.name === widget.name) + 1
const addedWidgets = node.widgets.splice(startingLength)
node.widgets.splice(insertionPoint, 0, ...addedWidgets)
syncNodeWidgetOrder(node)
if (inputInsertionPoint === 0) {
if (
addedWidgets.length === 0 &&
@@ -558,11 +541,8 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) {
for (const input of toRemove) {
const widgetName = input?.widget?.name
if (!widgetName) continue
for (const widget of remove(node.widgets, (w) => w.name === widgetName)) {
for (const widget of remove(node.widgets, (w) => w.name === widgetName))
widget.onRemove?.()
if (widget.widgetId) useWidgetValueStore().deleteWidget(widget.widgetId)
}
syncNodeWidgetOrder(node)
}
node.size[1] = node.computeSize([...node.size])[1]
}

View File

@@ -1,5 +1,3 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraph } from '@/lib/litegraph/src/litegraph'
@@ -63,8 +61,6 @@ async function createNodeWithFilenamePrefix(
describe('Comfy.SaveImageExtraOutput', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
const graph = new LGraph()
graph.add({
properties: { 'Node name for S&R': 'Sampler' },

View File

@@ -1,88 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useNodeBadgeStore } from '@/stores/nodeBadgeStore'
import { createUuidv4 } from '@/utils/uuid'
import {
createTestSubgraphData,
createTestSubgraphNode
} from './subgraph/__fixtures__/subgraphHelpers'
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
describe('LGraph node badge registration', () => {
it('registers a node in the root bucket on add, unregisters on remove', () => {
const graph = new LGraph()
const node = new LGraphNode('n')
graph.add(node)
expect(useNodeBadgeStore().registeredNodeIds(graph.rootGraph.id)).toEqual([
node.id
])
graph.remove(node)
expect(useNodeBadgeStore().registeredNodeIds(graph.rootGraph.id)).toEqual(
[]
)
})
it('clears the root bucket when the root graph is cleared', () => {
const graph = new LGraph()
graph.id = createUuidv4()
graph.add(new LGraphNode('a'))
graph.add(new LGraphNode('b'))
const graphId = graph.rootGraph.id
graph.clear()
expect(useNodeBadgeStore().registeredNodeIds(graphId)).toEqual([])
})
it('registers subgraph nodes in the root bucket', () => {
const rootGraph = new LGraph()
const subgraph = rootGraph.createSubgraph(createTestSubgraphData())
const inner = new LGraphNode('inner')
subgraph.add(inner)
expect(
useNodeBadgeStore().registeredNodeIds(rootGraph.rootGraph.id)
).toContainEqual(inner.id)
})
it('unregisters nodes at every nesting depth when a subgraph is cleared', () => {
const rootGraph = new LGraph()
const keeper = new LGraphNode('keeper')
rootGraph.add(keeper)
const outer = rootGraph.createSubgraph(createTestSubgraphData())
outer.add(new LGraphNode('direct'))
const nested = rootGraph.createSubgraph(createTestSubgraphData())
nested.add(new LGraphNode('deep'))
outer.add(createTestSubgraphNode(nested, { parentGraph: outer }))
outer.clear()
expect(
useNodeBadgeStore().registeredNodeIds(rootGraph.rootGraph.id)
).toEqual([keeper.id])
})
it('unregisters inner nodes when the subgraph definition is collected', () => {
const rootGraph = new LGraph()
const subgraph = rootGraph.createSubgraph(createTestSubgraphData())
const inner = new LGraphNode('inner')
subgraph.add(inner)
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
rootGraph.add(subgraphNode)
rootGraph.remove(subgraphNode)
expect(
useNodeBadgeStore().registeredNodeIds(rootGraph.rootGraph.id)
).toEqual([])
})
})

View File

@@ -1,14 +1,10 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe } from 'vitest'
import { describe } from 'vitest'
import { LGraph, LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { ISerialisedGraph } from '@/lib/litegraph/src/litegraph'
import { test } from './__fixtures__/testExtensions'
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
describe('LGraph Serialisation', () => {
test('can (de)serialise node / group titles', ({ expect, minimalGraph }) => {
const nodeTitle = 'Test Node'

View File

@@ -12,17 +12,11 @@ import {
Reroute,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type {
SerialisableGraph,
SerialisableLLink,
SerialisableReroute
} from '@/lib/litegraph/src/types/serialisation'
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
import type { UUID } from '@/utils/uuid'
import { zeroUuid } from '@/utils/uuid'
import { useLinkStore } from '@/stores/linkStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { slotFloatingLinks } from '@/lib/litegraph/src/LLink'
import { toLinkId } from '@/types/linkId'
import { toRerouteId } from '@/types/rerouteId'
import { UNASSIGNED_NODE_ID, toNodeId } from '@/types/nodeId'
@@ -45,8 +39,6 @@ import { nodeIdSpaceExhausted } from './__fixtures__/nodeIdSpaceExhausted'
import { uniqueSubgraphNodeIds } from './__fixtures__/uniqueSubgraphNodeIds'
import { test } from './__fixtures__/testExtensions'
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
function swapNodes(nodes: LGraphNode[]) {
const firstNode = nodes[0]
const lastNode = nodes[nodes.length - 1]
@@ -243,25 +235,6 @@ describe('Floating Links / Reroutes', () => {
expect(graph.reroutes.size).toBe(4)
})
test('slot floating links are derived from link endpoints', ({
expect,
linkedNodesGraph
}) => {
const graph = new LGraph(linkedNodesGraph)
graph.createReroute([0, 0], graph.links.values().next().value!)
const [origin, target] = graph.nodes
origin.disconnectOutput(0)
expect(slotFloatingLinks(graph, 'input', target.id, 0)).toHaveLength(1)
expect(slotFloatingLinks(graph, 'output', origin.id, 0)).toHaveLength(0)
const [floatingLink] = slotFloatingLinks(graph, 'input', target.id, 0)
graph.removeFloatingLink(floatingLink)
expect(slotFloatingLinks(graph, 'input', target.id, 0)).toHaveLength(0)
})
test('Floating reroutes should be removed when neither input nor output is connected', ({
expect,
floatingBranchGraph: graph
@@ -286,91 +259,6 @@ describe('Floating Links / Reroutes', () => {
})
})
describe('Link serialization goldens (ADR-0008 topology-store migration)', () => {
const LINK_KEYS = [
'id',
'origin_id',
'origin_slot',
'target_id',
'target_slot',
'type'
]
function expectContractKeyOrder(link: SerialisableLLink) {
const expectedKeys =
link.parentId === undefined ? LINK_KEYS : [...LINK_KEYS, 'parentId']
expect(Object.keys(link)).toEqual(expectedKeys)
}
test('plain links keep contract key order and round-trip byte-identically', ({
expect,
linkedNodesGraph
}) => {
const first = new LGraph(linkedNodesGraph).asSerialisable()
const second = new LGraph(first).asSerialisable()
expect(first.links?.length).toBeGreaterThan(0)
for (const link of first.links ?? []) expectContractKeyOrder(link)
expect(JSON.stringify(second.links)).toBe(JSON.stringify(first.links))
})
test('reroute-chain links keep contract key order and round-trip byte-identically', ({
expect,
reroutesComplexGraph
}) => {
const first = reroutesComplexGraph.asSerialisable()
const second = new LGraph(first).asSerialisable()
const chainedLinks = (first.links ?? []).filter(
(link) => link.parentId !== undefined
)
expect(chainedLinks.length).toBeGreaterThan(0)
for (const link of first.links ?? []) expectContractKeyOrder(link)
expect(JSON.stringify(second.links)).toBe(JSON.stringify(first.links))
})
test('floating links keep contract key order and round-trip byte-identically', ({
expect,
floatingLinkGraph
}) => {
const first = new LGraph(floatingLinkGraph).asSerialisable()
const second = new LGraph(first).asSerialisable()
expect(first.floatingLinks?.length).toBeGreaterThan(0)
for (const link of first.floatingLinks ?? []) expectContractKeyOrder(link)
expect(JSON.stringify(second.floatingLinks)).toBe(
JSON.stringify(first.floatingLinks)
)
})
const REROUTE_KEYS = ['id', 'parentId', 'pos', 'linkIds', 'floating'] as const
function expectRerouteContractKeyOrder(reroute: SerialisableReroute) {
const serialized: Record<string, unknown> = JSON.parse(
JSON.stringify(reroute)
)
const expectedKeys = REROUTE_KEYS.filter(
(key) => reroute[key] !== undefined
)
expect(Object.keys(serialized)).toEqual(expectedKeys)
}
test('reroutes keep contract key order and round-trip byte-identically', ({
expect,
reroutesComplexGraph
}) => {
const first = reroutesComplexGraph.asSerialisable()
const second = new LGraph(first).asSerialisable()
const reroutes = first.reroutes ?? []
expect(reroutes.length).toBeGreaterThan(0)
expect(reroutes.some((r) => r.floating !== undefined)).toBe(true)
expect(reroutes.some((r) => r.parentId === undefined)).toBe(true)
for (const reroute of reroutes) expectRerouteContractKeyOrder(reroute)
expect(JSON.stringify(second.reroutes)).toBe(JSON.stringify(first.reroutes))
})
})
describe('Graph Clearing and Callbacks', () => {
test('clear() calls both node.onRemoved() and graph.onNodeRemoved()', ({
expect
@@ -419,6 +307,8 @@ describe('Graph Clearing and Callbacks', () => {
})
test('clear() removes graph-scoped preview and widget-value state', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const graph = new LGraph()
const graphId = 'graph-clear-cleanup' as UUID
graph.id = graphId
@@ -454,6 +344,66 @@ describe('Graph Clearing and Callbacks', () => {
[]
)
})
test('clear() purges widget state on a zero-UUID graph that holds nodes (node-id reuse leak)', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const graph = new LGraph()
expect(graph.id).toBe(zeroUuid)
const node = new LGraphNode('ImageAnalyze')
node.id = toNodeId(1)
graph.add(node)
const widgetValueStore = useWidgetValueStore()
const modeWidgetId = widgetId(graph.id, toNodeId(1), 'mode')
widgetValueStore.registerWidget(modeWidgetId, {
type: 'combo',
value: 'Black White Levels',
options: {},
label: undefined,
serialize: undefined,
disabled: undefined
})
const previewExposureStore = usePreviewExposureStore()
previewExposureStore.addExposure(graph.id, `${graph.id}:1`, {
sourceNodeId: '1',
sourcePreviewName: '$$canvas-image-preview'
})
graph.clear()
expect(widgetValueStore.getWidget(modeWidgetId)).toBeUndefined()
expect(
previewExposureStore.getExposures(graph.id, `${graph.id}:1`)
).toEqual([])
})
test('constructing a new empty graph does not purge existing zero-UUID widget state', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const active = new LGraph()
const node = new LGraphNode('ImageAnalyze')
node.id = toNodeId(1)
active.add(node)
const widgetValueStore = useWidgetValueStore()
const modeWidgetId = widgetId(active.id, toNodeId(1), 'mode')
widgetValueStore.registerWidget(modeWidgetId, {
type: 'combo',
value: 'keep me',
options: {},
label: undefined,
serialize: undefined,
disabled: undefined
})
const throwaway = new LGraph()
expect(throwaway.id).toBe(zeroUuid)
expect(widgetValueStore.getWidget(modeWidgetId)?.value).toBe('keep me')
})
})
describe('node:before-removed event', () => {
@@ -547,6 +497,10 @@ describe('node:before-removed event', () => {
})
describe('Subgraph Definition Garbage Collection', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
function createSubgraphWithNodes(rootGraph: LGraph, nodeCount: number) {
const subgraph = rootGraph.createSubgraph(createTestSubgraphData())
@@ -936,7 +890,7 @@ describe('_removeDuplicateLinks', () => {
const linkId = toLinkId(Number(graph.state.lastLinkId) + 1)
graph.state.lastLinkId = linkId
const dup = new LLink(linkId, 'number', source.id, 0, target.id, 0)
graph._addLink(dup)
graph._links.set(dup.id, dup)
source.outputs[0].links!.push(dup.id)
return dup
}
@@ -970,20 +924,6 @@ describe('_removeDuplicateLinks', () => {
expect(graph._links.has(dupLink.id)).toBe(false)
})
it('drops purged duplicates from the link store and keeps the survivor indexed', () => {
const { graph, source, target } = createConnectedGraph()
const keptLinkId = target.inputs[0].link!
const dup = injectDuplicateLink(graph, source, target)
graph._removeDuplicateLinks()
const store = useLinkStore()
const graphId = graph.rootGraph.id
expect(dup._graphId).toBeUndefined()
expect(store.getInputSlotLink(graphId, target.id, 0)?.id).toBe(keptLinkId)
})
it('keeps the valid link when input.link is at a shifted slot index', () => {
const { graph, source, target } = createConnectedGraph()
const validLinkId = target.inputs[0].link!
@@ -1219,6 +1159,7 @@ describe('deduplicateSubgraphNodeIds (via configure)', () => {
const SHARED_NODE_IDS = [3, 8, 37]
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
LiteGraph.registerNodeType('dummy', DummyNode)
})

View File

@@ -1,5 +1,4 @@
import { toString } from 'es-toolkit/compat'
import { shallowRef, toRaw } from 'vue'
import {
SUBGRAPH_INPUT_ID,
@@ -9,13 +8,9 @@ import { isNodeBindable } from '@/lib/litegraph/src/utils/type'
import type { UUID } from '@/utils/uuid'
import { createUuidv4, zeroUuid } from '@/utils/uuid'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import { toLinkId } from '@/types/linkId'
import { toRerouteId } from '@/types/rerouteId'
import { useLinkStore } from '@/stores/linkStore'
import { useRerouteStore } from '@/stores/rerouteStore'
import { useNodeBadgeStore } from '@/stores/nodeBadgeStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { UNASSIGNED_NODE_ID, parseNodeId, toNodeId } from '@/types/nodeId'
@@ -34,20 +29,10 @@ import { LGraphCanvas } from './LGraphCanvas'
import { LGraphGroup } from './LGraphGroup'
import type { GroupId } from './LGraphGroup'
import { LGraphNode } from './LGraphNode'
import {
LLink,
registerLinkTopology,
unregisterAllLinkTopologies,
unregisterLinkTopology
} from './LLink'
import { LLink } from './LLink'
import type { LinkId } from './LLink'
import { MapProxyHandler } from './MapProxyHandler'
import {
registerRerouteChain,
Reroute,
unregisterAllRerouteChains,
unregisterRerouteChain
} from './Reroute'
import { Reroute } from './Reroute'
import type { RerouteId } from './Reroute'
import { CustomEventTarget } from './infrastructure/CustomEventTarget'
import type { LGraphEventMap } from './infrastructure/LGraphEventMap'
@@ -74,7 +59,6 @@ import {
snapPoint
} from './measure'
import { warnDeprecated } from './utils/feedback'
import { getWidgetIds } from './utils/widget'
import { SubgraphInput } from './subgraph/SubgraphInput'
import { SubgraphInputNode } from './subgraph/SubgraphInputNode'
import { SubgraphOutput } from './subgraph/SubgraphOutput'
@@ -108,7 +92,6 @@ import type {
import { getAllNestedItems } from './utils/collections'
import {
deduplicateSubgraphNodeIds,
deduplicateSubgraphRerouteIds,
topologicalSortSubgraphs
} from './subgraph/subgraphDeduplication'
@@ -198,55 +181,6 @@ function fireNodeRemovalLifecycle(node: LGraphNode): void {
graph?.onNodeRemoved?.(node)
}
/** A reroute chain segment, terminal-first. */
interface ChainSegment {
/** Emitted reroute ids, in walk order. */
segment: RerouteId[]
/** `false` if the walk stopped at a broken reference or a cycle. */
complete: boolean
}
/**
* Resolves one hop of a reroute chain.
* @param id The reroute id to resolve.
* @returns The id to emit and the next id upstream, or `undefined` if the
* reference is broken.
*/
type ChainStep = (
id: RerouteId
) => { emit: RerouteId; next: RerouteId | undefined } | undefined
/**
* Walks a reroute chain, resolving each hop with `step`, until it runs out,
* hits a broken reference, or detects a cycle.
* @param start The reroute id to walk from, or `undefined` for an empty chain.
* @param step Resolves each hop of the chain.
* @returns The walked segment.
*/
function walkSegment(
start: RerouteId | undefined,
step: ChainStep
): ChainSegment {
const segment: RerouteId[] = []
const visited = new Set<RerouteId>()
let id = start
while (id !== undefined) {
if (visited.has(id)) {
console.error('Infinite parentId loop when unpacking')
return { segment, complete: false }
}
visited.add(id)
const hop = step(id)
if (!hop) {
console.error('Broken Id link when unpacking')
return { segment, complete: false }
}
segment.push(hop.emit)
id = hop.next
}
return { segment, complete: true }
}
/**
* LGraph is the class that contain a full graph. We instantiate one and add nodes to it, and then we can run the execution loop.
* supported callbacks:
@@ -289,18 +223,7 @@ export class LGraph
'extra'
])
/**
* Ref-backed so the id reassignment on every workflow load ({@link configure})
* propagates to reactive consumers keyed by root graph id.
*/
private readonly _id = shallowRef<UUID>(zeroUuid)
get id(): UUID {
return toRaw(this)._id.value
}
set id(value: UUID) {
toRaw(this)._id.value = value
}
id: UUID = zeroUuid
revision: number = 0
_version: number = -1
@@ -469,20 +392,11 @@ export class LGraph
this.status = LGraph.STATUS_STOPPED
const graphId = this.id
if (this.isRootGraph && graphId !== zeroUuid) {
const isEmptyUnconfiguredGraph =
graphId === zeroUuid && this._nodes.length === 0
if (this.isRootGraph && !isEmptyUnconfiguredGraph) {
usePreviewExposureStore().clearGraph(graphId)
useWidgetValueStore().clearGraph(graphId)
useLinkStore().clearGraph(graphId)
useRerouteStore().clearGraph(graphId)
useNodeBadgeStore().clearGraph(graphId)
} else {
// Subgraphs and unconfigured (zero-uuid) graphs share their store
// bucket with other graphs, so unregister each link individually.
unregisterAllLinkTopologies(this)
unregisterAllRerouteChains(this)
forEachNode(this, (node) =>
useNodeBadgeStore().unregisterNode(this.rootGraph.id, node.id)
)
}
this.id = zeroUuid
@@ -1091,20 +1005,12 @@ export class LGraph
node.graph = this
this.incrementVersion()
useNodeBadgeStore().registerNode(this.rootGraph.id, node.id)
// Register all widgets with the WidgetValueStore now that node has a
// valid ID and graph reference.
if (node.widgets) {
const widgetValueStore = useWidgetValueStore()
for (const widget of node.widgets) {
if (isNodeBindable(widget)) widget.setNodeId(node.id)
}
widgetValueStore.setNodeWidgetOrder(
this.rootGraph.id,
node.id,
getWidgetIds(node.widgets)
)
}
this._nodes.push(node)
@@ -1208,11 +1114,6 @@ export class LGraph
if (!hasRemainingReferences) {
forEachNode(node.subgraph, fireNodeRemovalLifecycle)
forEachNode(node.subgraph, (innerNode) =>
useNodeBadgeStore().unregisterNode(this.rootGraph.id, innerNode.id)
)
unregisterAllLinkTopologies(node.subgraph)
unregisterAllRerouteChains(node.subgraph)
this.rootGraph.subgraphs.delete(node.subgraph.id)
}
}
@@ -1239,7 +1140,6 @@ export class LGraph
if (pos != -1) this._nodes.splice(pos, 1)
delete this._nodes_by_id[node.id]
useNodeBadgeStore().unregisterNode(this.rootGraph.id, node.id)
this.onNodeRemoved?.(node)
@@ -1524,48 +1424,49 @@ export class LGraph
link.id = toLinkId(++this._lastFloatingLinkId)
}
this.floatingLinksInternal.set(link.id, link)
registerLinkTopology(this, link)
const slot =
link.target_id !== UNASSIGNED_NODE_ID
? this.getNodeById(link.target_id)?.inputs?.[link.target_slot]
: this.getNodeById(link.origin_id)?.outputs?.[link.origin_slot]
if (slot) {
slot._floatingLinks ??= new Set()
slot._floatingLinks.add(link)
} else {
console.warn(
`Adding invalid floating link: target/slot: [${link.target_id}/${link.target_slot}] origin/slot: [${link.origin_id}/${link.origin_slot}]`
)
}
const reroutes = LLink.getReroutes(this, link)
for (const reroute of reroutes) {
reroute.floatingLinkIds.add(link.id)
}
return link
}
removeFloatingLink(link: LLink): void {
this.floatingLinksInternal.delete(link.id)
unregisterLinkTopology(link)
const slot =
link.target_id !== UNASSIGNED_NODE_ID
? this.getNodeById(link.target_id)?.inputs?.[link.target_slot]
: this.getNodeById(link.origin_id)?.outputs?.[link.origin_slot]
if (slot) {
slot._floatingLinks?.delete(link)
}
const reroutes = LLink.getReroutes(this, link)
for (const reroute of reroutes) {
reroute.floatingLinkIds.delete(link.id)
if (reroute.floatingLinkIds.size === 0) {
reroute.floating = undefined
delete reroute.floating
}
if (reroute.totalLinks === 0) this.removeReroute(reroute.id)
}
}
/**
* Adds a link to this graph's {@link _links} map and registers its topology
* with the link store. The single entry point for populating {@link _links};
* routing every add through here keeps the store from silently desyncing.
*/
_addLink(link: LLink): void {
this._links.set(link.id, link)
registerLinkTopology(this, link)
}
/**
* Removes a link from this graph's {@link _links} map and unregisters it
* from the link and layout stores. The delete-side counterpart to
* {@link _addLink}; routing every removal through here keeps the stores
* from silently desyncing.
*/
_removeLink(linkId: LinkId): void {
const link = this._links.get(linkId)
if (!link) return
this._links.delete(linkId)
unregisterLinkTopology(link)
layoutStore.deleteLinkLayout(linkId)
}
/**
* Finds the link with the provided ID.
* @param id ID of link to find
@@ -1588,32 +1489,6 @@ export class LGraph
return id == null ? undefined : this.reroutes.get(id)
}
/**
* Adds a reroute to this graph's {@link reroutes} map and registers its
* chain state with the reroute store. The single entry point for
* populating {@link reroutes}; routing every add through here keeps the
* store from silently desyncing.
*/
_addReroute(reroute: Reroute): void {
this.reroutesInternal.set(reroute.id, reroute)
registerRerouteChain(this, reroute)
}
/**
* Removes a reroute from this graph's {@link reroutes} map and
* unregisters it from the reroute and layout stores. The delete-side
* counterpart to {@link _addReroute}.
*/
_removeReroute(id: RerouteId): void {
const reroute = this.reroutesInternal.get(id)
if (!reroute) return
this.reroutesInternal.delete(id)
unregisterRerouteChain(reroute)
const layoutMutations = useLayoutMutations()
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.deleteReroute(id)
}
/**
* Configures a reroute on the graph where ID is already known (probably deserialisation).
* Creates the object if it does not exist.
@@ -1623,6 +1498,7 @@ export class LGraph
id,
parentId,
pos,
linkIds,
floating
}: OptionalProps<SerialisableReroute, 'id'>): Reroute {
const rerouteId =
@@ -1634,11 +1510,11 @@ export class LGraph
}
const reroute = this.reroutes.get(rerouteId) ?? new Reroute(rerouteId, this)
reroute.parentId =
const typedParentId =
parentId === undefined ? undefined : toRerouteId(parentId)
if (pos) reroute.pos = pos
reroute.floating = floating
this._addReroute(reroute)
const typedLinkIds = linkIds?.map(toLinkId)
reroute.update(typedParentId, pos, typedLinkIds, floating)
this.reroutes.set(rerouteId, reroute)
return reroute
}
@@ -1656,24 +1532,41 @@ export class LGraph
}
const rerouteId = toRerouteId(Number(this.state.lastRerouteId) + 1)
this.state.lastRerouteId = rerouteId
const chainLinks =
before instanceof Reroute
? [
...[...before.linkIds].map((id) => this._links.get(id)),
...[...before.floatingLinkIds].map((id) =>
this.floatingLinks.get(id)
)
]
: [before]
const reroute = new Reroute(rerouteId, this, pos, before.parentId)
this._addReroute(reroute)
const linkIds = before instanceof Reroute ? before.linkIds : [before.id]
const floatingLinkIds =
before instanceof Reroute ? before.floatingLinkIds : [before.id]
const reroute = new Reroute(
rerouteId,
this,
pos,
before.parentId,
linkIds,
floatingLinkIds
)
this.reroutes.set(rerouteId, reroute)
// Register reroute in Layout Store for spatial tracking
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.createReroute(rerouteId, { x: pos[0], y: pos[1] })
layoutMutations.createReroute(
rerouteId,
{ x: pos[0], y: pos[1] },
before.parentId,
Array.from(linkIds)
)
// Splice the new reroute into every chain that contained `before`
for (const link of chainLinks) {
for (const linkId of linkIds) {
const link = this._links.get(linkId)
if (!link) continue
if (link.parentId === before.parentId) link.parentId = rerouteId
const reroutes = LLink.getReroutes(this, link)
for (const x of reroutes.filter((x) => x.parentId === before.parentId)) {
x.parentId = rerouteId
}
}
for (const linkId of floatingLinkIds) {
const link = this.floatingLinks.get(linkId)
if (!link) continue
if (link.parentId === before.parentId) link.parentId = rerouteId
@@ -1691,6 +1584,7 @@ export class LGraph
* @param id ID of reroute to remove
*/
removeReroute(id: RerouteId): void {
const layoutMutations = useLayoutMutations()
const { reroutes } = this
const reroute = reroutes.get(id)
if (!reroute) return
@@ -1733,7 +1627,11 @@ export class LGraph
}
}
this._removeReroute(id)
reroutes.delete(id)
// Delete reroute from Layout Store
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.deleteReroute(id)
// This does not belong here; it should be handled by the caller, or run by a remove-many API.
// https://github.com/Comfy-Org/litegraph.js/issues/898
@@ -1771,7 +1669,9 @@ export class LGraph
const node = this.getNodeById(sampleLink.target_id)
const keepId = selectSurvivorLink(ids, node)
purgeOrphanedLinks(ids, keepId, this)
purgeOrphanedLinks(ids, keepId, this._links, (id) =>
this.getNodeById(toNodeId(id))
)
repairInputLinks(ids, keepId, node)
}
}
@@ -2010,7 +1910,7 @@ export class LGraph
if (link.target_id === SUBGRAPH_OUTPUT_ID) {
link.origin_id = subgraphNode.id
link.origin_slot = i - 1
this._addLink(link)
this.links.set(link.id, link)
if (subgraphOutput instanceof SubgraphOutput) {
subgraphOutput.connect(
subgraphNode.findOutputSlotByType(link.type, true, true),
@@ -2144,6 +2044,30 @@ export class LGraph
group.pos[1] += offsetY
toSelect.push(group)
}
//cleanup reoute.linkIds now, but leave link.parentIds dangling
for (const islot of subgraphNode.inputs) {
if (!islot.link) continue
const link = this.links.get(islot.link)
if (!link) {
console.warn('Broken link', islot, islot.link)
continue
}
for (const reroute of LLink.getReroutes(this, link)) {
reroute.linkIds.delete(link.id)
}
}
for (const oslot of subgraphNode.outputs) {
for (const linkId of oslot.links ?? []) {
const link = this.links.get(linkId)
if (!link) {
console.warn('Broken link', oslot, linkId)
continue
}
for (const reroute of LLink.getReroutes(this, link)) {
reroute.linkIds.delete(link.id)
}
}
}
const newLinks: {
oid: NodeId
oslot: number
@@ -2278,52 +2202,89 @@ export class LGraph
}
newLink.id = created.id
}
// Migrate the subgraph's reroutes to fresh ids at their new positions.
const rerouteIdMap = new Map<RerouteId, RerouteId>()
const oldReroutes = subgraphNode.subgraph.reroutes
for (const reroute of oldReroutes.values()) {
const migratedId = toRerouteId(Number(this.state.lastRerouteId) + 1)
this.state.lastRerouteId = migratedId
const migratedReroute = new Reroute(migratedId, this, [
for (const reroute of subgraphNode.subgraph.reroutes.values()) {
if (
reroute.parentId !== undefined &&
rerouteIdMap.get(reroute.parentId) === undefined
) {
console.error('Missing Parent ID')
}
const migratedRerouteId = toRerouteId(
Number(this.state.lastRerouteId) + 1
)
this.state.lastRerouteId = migratedRerouteId
const migratedReroute = new Reroute(migratedRerouteId, this, [
reroute.pos[0] + offsetX,
reroute.pos[1] + offsetY
])
rerouteIdMap.set(reroute.id, migratedId)
this._addReroute(migratedReroute)
rerouteIdMap.set(reroute.id, migratedReroute.id)
this.reroutes.set(migratedReroute.id, migratedReroute)
toSelect.push(migratedReroute)
}
// Stitch each link's chain from its internal (migrated) and external
// segments, ordered by which side was nearest the input. External hops walk
// this graph's own reroutes; internal hops walk the old subgraph chain,
// emitting migrated ids.
//iterate over newly created links to update reroute parentIds
for (const newLink of dedupedNewLinks) {
const linkInstance = this.links.get(newLink.id)
if (!linkInstance) continue
const internal = walkSegment(newLink.iparent, (id) => {
const emit = rerouteIdMap.get(id)
return emit === undefined
? undefined
: { emit, next: oldReroutes.get(id)?.parentId }
})
const external = walkSegment(newLink.eparent, (id) => {
const reroute = this.reroutes.get(id)
return reroute && { emit: id, next: reroute.parentId }
})
const [first, second] = newLink.externalFirst
? [external, internal]
: [internal, external]
const chain = first.complete
? [...first.segment, ...second.segment]
: first.segment
let segmentEnd: LLink | Reroute = linkInstance
for (const rerouteId of chain) {
segmentEnd.parentId = rerouteId
const next = this.reroutes.get(rerouteId)
if (!next) break
segmentEnd = next
if (!linkInstance) {
continue
}
let instance: Reroute | LLink | undefined = linkInstance
let parentId: RerouteId | undefined
if (newLink.externalFirst) {
parentId = newLink.eparent
//TODO: recursion check/helper method? Probably exists, but wouldn't mesh with the reference tracking used by this implementation
while (parentId) {
instance.parentId = parentId
instance = this.reroutes.get(parentId)
if (!instance) {
console.error('Broken Id link when unpacking')
break
}
if (instance.linkIds.has(linkInstance.id))
throw new Error('Infinite parentId loop')
instance.linkIds.add(linkInstance.id)
parentId = instance.parentId
}
}
if (!instance) continue
parentId = newLink.iparent
while (parentId) {
const migratedId = rerouteIdMap.get(parentId)
if (!migratedId) {
console.error('Broken Id link when unpacking')
break
}
instance.parentId = migratedId
instance = this.reroutes.get(migratedId)
if (!instance) {
console.error('Broken Id link when unpacking')
break
}
if (instance.linkIds.has(linkInstance.id))
throw new Error('Infinite parentId loop')
instance.linkIds.add(linkInstance.id)
const oldReroute = subgraphNode.subgraph.reroutes.get(parentId)
if (!oldReroute) {
console.error('Broken Id link when unpacking')
break
}
parentId = oldReroute.parentId
}
if (!instance) break
if (!newLink.externalFirst) {
parentId = newLink.eparent
while (parentId) {
instance.parentId = parentId
instance = this.reroutes.get(parentId)
if (!instance) {
console.error('Broken Id link when unpacking')
break
}
if (instance.linkIds.has(linkInstance.id))
throw new Error('Infinite parentId loop')
instance.linkIds.add(linkInstance.id)
parentId = instance.parentId
}
}
}
@@ -2512,6 +2473,7 @@ export class LGraph
data: ISerialisedGraph | SerialisableGraph,
keep_old?: boolean
): boolean | undefined {
const layoutMutations = useLayoutMutations()
const options: LGraphEventMap['configuring'] = {
data,
clearGraph: !keep_old
@@ -2535,7 +2497,7 @@ export class LGraph
if (Array.isArray(data.links)) {
for (const linkData of data.links) {
const link = LLink.createFromArray(linkData)
this._addLink(link)
this._links.set(link.id, link)
}
}
// #region `extra` embeds for v0.4
@@ -2576,7 +2538,7 @@ export class LGraph
if (Array.isArray(data.links)) {
for (const linkData of data.links) {
const link = LLink.create(linkData)
this._addLink(link)
this._links.set(link.id, link)
}
}
@@ -2630,20 +2592,6 @@ export class LGraph
)
: undefined
if (deduplicated) {
const reservedRerouteIds = new Set<number>()
for (const reroute of this.reroutes.values())
reservedRerouteIds.add(Number(reroute.id))
for (const sg of this.subgraphs.values())
for (const reroute of sg.reroutes.values())
reservedRerouteIds.add(Number(reroute.id))
deduplicateSubgraphRerouteIds(
deduplicated.subgraphs,
reservedRerouteIds,
this.state
)
}
const finalSubgraphs = deduplicated?.subgraphs ?? subgraphs
effectiveNodesData = deduplicated?.rootNodes ?? nodesData
@@ -2713,10 +2661,14 @@ export class LGraph
}
}
// Drop reroutes that no live link or floating link passes through
// Drop broken reroutes
for (const reroute of this.reroutes.values()) {
if (reroute.totalLinks === 0) {
this._removeReroute(reroute.id)
// Drop broken links, and ignore reroutes with no valid links
if (!reroute.validateLinks(this._links, this.floatingLinks)) {
this.reroutes.delete(reroute.id)
// Clean up layout store
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.deleteReroute(reroute.id)
}
}

View File

@@ -34,8 +34,6 @@ vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
function createSerialisedNode(
id: number,
type: string,
@@ -258,31 +256,6 @@ describe('_deserializeItems paste-time migration & auto-expose', () => {
registeredTypesToCleanup.push(type)
}
it('prunes pasted reroutes that no pasted link passes through', () => {
const nodeType = 'test/clipboard-reroute-prune'
registerClipboardNodeType(nodeType)
const rootGraph = new LGraph()
const canvas = createCanvas(rootGraph)
const source = LiteGraph.createNode(nodeType)!
rootGraph.add(source)
const target = LiteGraph.createNode(nodeType)!
rootGraph.add(target)
const link = source.connect(0, target, 0)!
rootGraph.createReroute([50, 50], link)
// Copying only the reroute leaves it with no pasted link through it
const result = canvas._deserializeItems(
canvas._serializeItems([...rootGraph.reroutes.values()]),
{ position: [300, 300] }
)
expect(result?.reroutes.size).toBe(0)
expect(result?.created).toHaveLength(0)
expect(rootGraph.reroutes.size).toBe(1)
})
it('reconnects pasted inputs when clipboard node IDs differ from link endpoint types', () => {
const nodeType = 'test/clipboard-node-id-normalization'
registerClipboardNodeType(nodeType)

View File

@@ -1,5 +1,3 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NodeId } from '@/types/nodeId'
@@ -15,8 +13,6 @@ import {
LiteGraph
} from '@/lib/litegraph/src/litegraph'
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
const TEST_NODE_TYPE = 'test/CloneZIndex' as const
class TestNode extends LGraphNode {

View File

@@ -93,7 +93,7 @@ describe('drawConnections widget-input slot positioning', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
setActivePinia(createTestingPinia())
canvasElement = document.createElement('canvas')
canvasElement.width = 800

View File

@@ -1,6 +1,4 @@
import userEvent from '@testing-library/user-event'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraph, LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -17,8 +15,6 @@ vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
}
}))
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
function createGhostTestHarness() {
const canvasElement = document.createElement('canvas')
canvasElement.width = 800

View File

@@ -1,6 +1,4 @@
import { fromAny } from '@total-typescript/shoehorn'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
@@ -22,8 +20,6 @@ vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
}
}))
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
function createCanvas(graph: LGraph): LGraphCanvas {
const el = document.createElement('canvas')
el.width = 800

View File

@@ -1,5 +1,3 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
@@ -21,8 +19,6 @@ vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
}
}))
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
describe('LGraphCanvas slot hit detection', () => {
let graph: LGraph
let canvas: LGraphCanvas

View File

@@ -26,7 +26,7 @@ import { LGraphNode } from './LGraphNode'
import type { NodeProperty } from './LGraphNode'
import { parseNodeId, serializeNodeId } from '@/types/nodeId'
import type { SerializedNodeId } from '@/types/nodeId'
import { LLink, slotFloatingLinks } from './LLink'
import { LLink } from './LLink'
import type { LinkId } from './LLink'
import { Reroute } from './Reroute'
import type { RerouteId } from './Reroute'
@@ -2784,8 +2784,13 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
output: INodeOutputSlot,
network: LinkNetwork
): boolean {
return (output.links ?? []).some(
(linkId) => network.getLink(linkId) !== undefined
const outputLinks = [
...(output.links ?? []),
...[...(output._floatingLinks ?? new Set())]
]
return outputLinks.some(
(linkId) =>
typeof linkId === 'number' && network.getLink(linkId) !== undefined
)
}
@@ -2796,7 +2801,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (isInRectangle(x, y, link_pos[0] - 15, link_pos[1] - 10, 30, 20)) {
// Drag multiple output links
if (e.shiftKey && hasRelevantOutputLinks(output, graph)) {
linkConnector.moveOutputLink(graph, node, output)
linkConnector.moveOutputLink(graph, output)
this._linkConnectorDrop()
return
}
@@ -2842,15 +2847,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
ctrlOrMeta &&
e.altKey &&
!e.shiftKey
if (
input.link !== null ||
slotFloatingLinks(graph, 'input', node.id, i).length > 0
) {
if (input.link !== null || input._floatingLinks?.size) {
// Existing link
if (shouldBreakLink || LiteGraph.click_do_break_link_to) {
node.disconnectInput(i, true)
} else if (e.shiftKey || this.allow_reconnect_links) {
linkConnector.moveInputLink(graph, node, input)
linkConnector.moveInputLink(graph, input)
}
}
@@ -4292,14 +4294,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
}
// Remove reroutes that no pasted link passes through
for (const [sourceId, reroute] of reroutes) {
if (reroute.totalLinks === 0) {
graph.removeReroute(reroute.id)
reroutes.delete(sourceId)
// Remap linkIds
for (const reroute of reroutes.values()) {
const ids = [...reroute.linkIds].map((x) => links.get(x)?.id ?? x)
reroute.update(reroute.parentId, undefined, ids, reroute.floating)
const index = created.indexOf(reroute)
if (index !== -1) created.splice(index, 1)
// Remove any invalid items
if (!reroute.validateLinks(graph.links, graph.floatingLinks)) {
graph.removeReroute(reroute.id)
}
}

View File

@@ -9,7 +9,6 @@ import type { SlotPositionContext } from '@/renderer/core/canvas/litegraph/slotC
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import { toLinkId } from '@/types/linkId'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { UNASSIGNED_NODE_ID, toNodeId, serializeNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
import { adjustColor } from '@/utils/colorUtil'
@@ -31,8 +30,7 @@ import { BadgePosition, LGraphBadge } from './LGraphBadge'
import { LGraphButton } from './LGraphButton'
import type { LGraphButtonOptions } from './LGraphButton'
import { LGraphCanvas } from './LGraphCanvas'
import { LLink, slotFloatingLinks } from './LLink'
import { anchorRerouteChain } from './Reroute'
import { LLink } from './LLink'
import type { Reroute, RerouteId } from './Reroute'
import { getNodeInputOnPos, getNodeOutputOnPos } from './canvas/measureSlots'
import type { IDrawBoundingOptions } from './draw'
@@ -98,7 +96,6 @@ import type {
} from './types/widgets'
import { findFreeSlotOfType } from './utils/collections'
import { warnDeprecated } from './utils/feedback'
import { getWidgetIds } from './utils/widget'
import { distributeSpace } from './utils/spaceDistribution'
import { truncateText } from './utils/textUtils'
import { BaseWidget } from './widgets/BaseWidget'
@@ -1690,15 +1687,6 @@ export class LGraphNode
}
}
}
if (this.graph) {
for (const floatingLink of this.graph.floatingLinks.values()) {
if (
floatingLink.origin_id === this.id &&
floatingLink.origin_slot > slot
)
floatingLink.origin_slot--
}
}
this.onOutputRemoved?.(slot)
this.setDirtyCanvas(true, true)
@@ -1753,15 +1741,6 @@ export class LGraphNode
if (link) link.target_slot--
}
}
if (this.graph) {
for (const floatingLink of this.graph.floatingLinks.values()) {
if (
floatingLink.target_id === this.id &&
floatingLink.target_slot > slot
)
floatingLink.target_slot--
}
}
this.onInputRemoved?.(slot, slot_info[0])
this.setDirtyCanvas(true, true)
}
@@ -2076,20 +2055,6 @@ export class LGraphNode
widget.onRemove?.()
this.widgets.splice(widgetIndex, 1)
const graphId = this.graph?.rootGraph.id
if (graphId) {
const widgetValueStore = useWidgetValueStore()
// Drop the widget from the render order but keep its stored value, so a
// remove-then-re-add of the same widget id preserves what the user set.
if (widget.widgetId)
widgetValueStore.removeNodeWidgetOrder(widget.widgetId)
widgetValueStore.setNodeWidgetOrder(
graphId,
this.id,
getWidgetIds(this.widgets)
)
}
}
ensureWidgetRemoved(widget: IBaseWidget): void {
@@ -2926,6 +2891,8 @@ export class LGraphNode
const { graph } = this
if (!graph) throw new NullGraphError()
const layoutMutations = useLayoutMutations()
const outputIndex = this.outputs.indexOf(output)
if (outputIndex === -1) {
console.warn('connectSlots: output not found')
@@ -2968,7 +2935,7 @@ export class LGraphNode
// if there is something already plugged there, disconnect
if (inputNode.inputs[inputIndex]?.link != null) {
graph.beforeChange()
inputNode.disconnectInput(inputIndex, true, afterRerouteId)
inputNode.disconnectInput(inputIndex, true)
}
const maybeCommonType =
@@ -2988,15 +2955,52 @@ export class LGraphNode
)
// add to graph links list
graph._addLink(link)
graph._links.set(link.id, link)
// Register link in Layout Store for spatial tracking
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.createLink(
link.id,
this.id,
outputIndex,
inputNode.id,
inputIndex
)
// connect in output
output.links ??= []
output.links.push(link.id)
// connect in input
inputNode.inputs[inputIndex].link = link.id
const targetInput = inputNode.inputs[inputIndex]
targetInput.link = link.id
if (targetInput.widget) {
graph.trigger('node:slot-links:changed', {
nodeId: inputNode.id,
slotType: NodeSlotType.INPUT,
slotIndex: inputIndex,
connected: true,
linkId: link.id
})
}
anchorRerouteChain(graph, link)
// Reroutes
const reroutes = LLink.getReroutes(graph, link)
for (const reroute of reroutes) {
reroute.linkIds.add(link.id)
if (reroute.floating) reroute.floating = undefined
reroute._dragging = undefined
}
// If this is the terminus of a floating link, remove it
const lastReroute = reroutes.at(-1)
if (lastReroute) {
for (const linkId of lastReroute.floatingLinkIds) {
const link = graph.floatingLinks.get(linkId)
if (link?.parentId === lastReroute.id) {
graph.removeFloatingLink(link)
}
}
}
graph.incrementVersion()
// link has been created now, so its updated
@@ -3071,6 +3075,7 @@ export class LGraphNode
if (!link)
throw new Error('[connectFloatingReroute] Floating link not found')
reroute.floatingLinkIds.add(link.id)
link.parentId = reroute.id
parentReroute.floating = undefined
return reroute
@@ -3101,14 +3106,11 @@ export class LGraphNode
const output = this.outputs[slot]
if (!output) return false
if (this.graph) {
for (const link of slotFloatingLinks(
this.graph,
'output',
this.id,
slot
)) {
this.graph.removeFloatingLink(link)
if (output._floatingLinks) {
for (const link of output._floatingLinks) {
if (link.hasOrigin(this.id, slot)) {
this.graph?.removeFloatingLink(link)
}
}
}
@@ -3136,6 +3138,15 @@ export class LGraphNode
const input = target.inputs[link_info.target_slot]
// remove there
input.link = null
if (input.widget) {
graph.trigger('node:slot-links:changed', {
nodeId: target.id,
slotType: NodeSlotType.INPUT,
slotIndex: link_info.target_slot,
connected: false,
linkId: link_info.id
})
}
// remove the link from the links pool
link_info.disconnect(graph, 'input')
@@ -3183,6 +3194,15 @@ export class LGraphNode
const input = target.inputs[link_info.target_slot]
// remove other side link
input.link = null
if (input.widget) {
graph.trigger('node:slot-links:changed', {
nodeId: target.id,
slotType: NodeSlotType.INPUT,
slotIndex: link_info.target_slot,
connected: false,
linkId: link_info.id
})
}
// link_info hasn't been modified so its ok
target.onConnectionsChange?.(
@@ -3215,15 +3235,9 @@ export class LGraphNode
* Disconnect one input
* @param slot Input slot index, or the name of the slot
* @param keepReroutes If `true`, reroutes will not be garbage collected.
* @param keepFloatingReroute Floating link(s) parented to this reroute are left
* intact, so a chain being reconnected is not pruned before its new link exists.
* @returns true if disconnected successfully or already disconnected, otherwise false
*/
disconnectInput(
slot: number | string,
keepReroutes?: boolean,
keepFloatingReroute?: RerouteId
): boolean {
disconnectInput(slot: number | string, keepReroutes?: boolean): boolean {
// Allow search by string
if (typeof slot === 'string') {
slot = this.findInputSlot(slot)
@@ -3248,16 +3262,25 @@ export class LGraphNode
const { graph } = this
if (!graph) throw new NullGraphError()
// Break floating links, except the one whose reroute chain is being
// reconnected (its reroute would be pruned before the new link is added).
for (const link of slotFloatingLinks(graph, 'input', this.id, slot)) {
if (link.parentId === keepFloatingReroute) continue
graph.removeFloatingLink(link)
// Break floating links
if (input._floatingLinks?.size) {
for (const link of input._floatingLinks) {
graph.removeFloatingLink(link)
}
}
const link_id = this.inputs[slot].link
if (link_id != null) {
this.inputs[slot].link = null
if (input.widget) {
graph.trigger('node:slot-links:changed', {
nodeId: this.id,
slotType: NodeSlotType.INPUT,
slotIndex: slot,
connected: false,
linkId: link_id
})
}
// remove other side
const link_info = graph._links.get(link_id)

View File

@@ -1,221 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { computed } from 'vue'
import { LGraph, LGraphNode, LLink } from '@/lib/litegraph/src/litegraph'
import { useLinkStore } from '@/stores/linkStore'
import { toLinkId } from '@/types/linkId'
import { UNASSIGNED_NODE_ID } from '@/types/nodeId'
import { toRerouteId } from '@/types/rerouteId'
import { registerLinkTopology } from './LLink'
import {
createTestSubgraph,
createTestSubgraphNode
} from './subgraph/__fixtures__/subgraphHelpers'
describe('LLink ↔ linkStore integration', () => {
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
it('connect registers, disconnect removes', () => {
const graph = new LGraph()
const a = new LGraphNode('A')
const b = new LGraphNode('B')
a.addOutput('out', 'INT')
b.addInput('in', 'INT')
graph.add(a)
graph.add(b)
const link = a.connect(0, b, 0)!
const store = useLinkStore()
expect(store.isInputSlotConnected(graph.rootGraph.id, b.id, 0)).toBe(true)
graph.removeLink(link.id)
expect(store.isInputSlotConnected(graph.rootGraph.id, b.id, 0)).toBe(false)
})
it('link.parentId writes are observable through the store query', () => {
const graph = new LGraph()
const a = new LGraphNode('A')
const b = new LGraphNode('B')
a.addOutput('out', 'INT')
b.addInput('in', 'INT')
graph.add(a)
graph.add(b)
const link = a.connect(0, b, 0)!
const store = useLinkStore()
const parentId = computed(
() => store.getInputSlotLink(graph.rootGraph.id, b.id, 0)?.parentId
)
expect(parentId.value).toBeUndefined()
link.parentId = toRerouteId(7)
expect(parentId.value).toBe(7)
})
it('keeps writing to a disconnected link after it leaves the store', () => {
const graph = new LGraph()
const a = new LGraphNode('A')
const b = new LGraphNode('B')
a.addOutput('out', 'INT')
b.addInput('in0', 'INT')
b.addInput('in1', 'INT')
graph.add(a)
graph.add(b)
const link = a.connect(0, b, 0)!
graph.removeLink(link.id)
expect(() => {
link.target_slot = 3
}).not.toThrow()
expect(link.target_slot).toBe(3)
})
it('keeps the winner registered when a colliding loser link disconnects', () => {
const graph = new LGraph()
const a = new LGraphNode('A')
const b = new LGraphNode('B')
a.addOutput('out', 'INT')
b.addInput('in', 'INT')
graph.add(a)
graph.add(b)
const winner = a.connect(0, b, 0)!
const loser = new LLink(winner.id, 'INT', a.id, 0, b.id, 0)
registerLinkTopology(graph, loser)
loser.disconnect(graph)
const store = useLinkStore()
const graphId = graph.rootGraph.id
expect(store.getInputSlotLink(graphId, b.id, 0)?.id).toBe(winner.id)
expect(store.isInputSlotConnected(graphId, b.id, 0)).toBe(true)
})
it('unregisters a subgraph definitions links when its last instance is removed', () => {
const subgraph = createTestSubgraph({ nodeCount: 2 })
const [first, second] = subgraph.nodes
const innerLink = first.connect(0, second, 0)!
const rootGraph = subgraph.rootGraph
const subgraphNode = createTestSubgraphNode(subgraph)
rootGraph.add(subgraphNode)
const store = useLinkStore()
expect(store.getInputSlotLink(rootGraph.id, second.id, 0)?.id).toBe(
innerLink.id
)
rootGraph.remove(subgraphNode)
expect(store.isInputSlotConnected(rootGraph.id, second.id, 0)).toBe(false)
})
it('keeps a subgraph definitions links registered while other instances remain', () => {
const subgraph = createTestSubgraph({ nodeCount: 2 })
const [first, second] = subgraph.nodes
const innerLink = first.connect(0, second, 0)!
const rootGraph = subgraph.rootGraph
const keptInstance = createTestSubgraphNode(subgraph)
const removedInstance = createTestSubgraphNode(subgraph, { id: 99 })
rootGraph.add(keptInstance)
rootGraph.add(removedInstance)
rootGraph.remove(removedInstance)
const store = useLinkStore()
expect(store.getInputSlotLink(rootGraph.id, second.id, 0)?.id).toBe(
innerLink.id
)
})
it('clearing a subgraph unregisters its links but keeps root links', () => {
const subgraph = createTestSubgraph({ nodeCount: 2 })
const rootGraph = subgraph.rootGraph
const [first, second] = subgraph.nodes
first.connect(0, second, 0)
const a = new LGraphNode('A')
const b = new LGraphNode('B')
a.addOutput('out', '*')
b.addInput('in', '*')
rootGraph.add(a)
rootGraph.add(b)
const rootLink = a.connect(0, b, 0)!
subgraph.clear()
const store = useLinkStore()
expect(store.isInputSlotConnected(rootGraph.id, second.id, 0)).toBe(false)
expect(store.getInputSlotLink(rootGraph.id, b.id, 0)?.id).toBe(rootLink.id)
})
it('clear() unregisters an unconfigured graphs links from the store', () => {
const graph = new LGraph()
const a = new LGraphNode('A')
const b = new LGraphNode('B')
a.addOutput('out', 'INT')
b.addInput('in', 'INT')
graph.add(a)
graph.add(b)
const link = a.connect(0, b, 0)!
const graphId = graph.rootGraph.id
const store = useLinkStore()
expect(store.getInputSlotLink(graphId, b.id, 0)?.id).toBe(link.id)
graph.clear()
expect(store.isInputSlotConnected(graphId, b.id, 0)).toBe(false)
})
it('detaches a floating link from the store when it is removed', () => {
const graph = new LGraph()
const a = new LGraphNode('A')
a.addOutput('out', '*')
graph.add(a)
const floating = new LLink(
toLinkId(7),
'*',
a.id,
0,
UNASSIGNED_NODE_ID,
-1
)
graph.addFloatingLink(floating)
const graphId = graph.rootGraph.id
expect(floating._graphId).toBe(graphId)
graph.removeFloatingLink(floating)
expect(floating._graphId).toBeUndefined()
floating.origin_slot = 5
expect(floating.origin_slot).toBe(5)
})
it('moving a link via target_slot reindexes the store', () => {
const graph = new LGraph()
const a = new LGraphNode('A')
const b = new LGraphNode('B')
a.addOutput('out', 'INT')
b.addInput('in0', 'INT')
b.addInput('in1', 'INT')
graph.add(a)
graph.add(b)
const link = a.connect(0, b, 0)!
const store = useLinkStore()
const nodeId = b.id
expect(store.isInputSlotConnected(graph.rootGraph.id, nodeId, 0)).toBe(true)
link.target_slot = 1
expect(store.isInputSlotConnected(graph.rootGraph.id, nodeId, 0)).toBe(
false
)
expect(store.isInputSlotConnected(graph.rootGraph.id, nodeId, 1)).toBe(true)
})
})

View File

@@ -2,7 +2,6 @@ import { describe, expect } from 'vitest'
import { LLink } from '@/lib/litegraph/src/litegraph'
import { toLinkId } from '@/types/linkId'
import { toNodeId } from '@/types/nodeId'
import { test } from './__fixtures__/testExtensions'
@@ -22,17 +21,4 @@ describe('LLink', () => {
expect(link.hasOrigin(4, 2)).toBe(true)
expect(link.hasTarget(5, 3)).toBe(true)
})
test('exposes topology fields backed by a single _state object', () => {
const link = new LLink(toLinkId(1), 'INT', 5, 0, 9, 2)
expect(link.origin_id).toBe(toNodeId(5))
link.target_slot = 4
expect(link._state.targetSlot).toBe(4)
expect(link.asSerialisable()).toMatchObject({
id: toLinkId(1),
origin_id: 5,
target_slot: 4,
type: 'INT'
})
})
})

View File

@@ -4,18 +4,14 @@ import {
} from '@/lib/litegraph/src/constants'
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
import type { SubgraphOutput } from '@/lib/litegraph/src/subgraph/SubgraphOutput'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useLinkStore } from '@/stores/linkStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import { toLinkId } from '@/types/linkId'
import { UNASSIGNED_NODE_ID, toNodeId, serializeNodeId } from '@/types/nodeId'
import { toRerouteId } from '@/types/rerouteId'
import type { EndpointPatch } from '@/stores/linkStore'
import type { LinkId } from '@/types/linkId'
import type { LinkTopology } from '@/types/linkTopology'
import type { RerouteId } from '@/types/rerouteId'
import type { UUID } from '@/utils/uuid'
import type { LGraph } from './LGraph'
import type { LGraphNode } from './LGraphNode'
import type { NodeId, SerializedNodeId } from '@/types/nodeId'
import type { Reroute } from './Reroute'
@@ -31,6 +27,8 @@ import type {
} from './interfaces'
import type { Serialisable, SerialisableLLink } from './types/serialisation'
const layoutMutations = useLayoutMutations()
export type { LinkId } from '@/types/linkId'
export type SerialisedLLinkArray = [
id: number,
@@ -95,93 +93,22 @@ type BasicReadonlyNetwork = Pick<
'getNodeById' | 'links' | 'getLink' | 'inputNode' | 'outputNode'
>
/** Routes an endpoint patch through {@link useLinkStore} if the link is registered, otherwise writes {@link LLink._state} directly. */
function applyEndpointPatch(link: LLink, patch: EndpointPatch): void {
if (link._graphId) {
const registered = useLinkStore().updateEndpoint(
link._graphId,
link._state,
patch
)
if (!registered) link._graphId = undefined
} else {
Object.assign(link._state, patch)
}
}
// this is the class in charge of storing link information
export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
static _drawDebug = false
/**
* The link's topology state. Once registered with {@link useLinkStore},
* this is the store's reactive proxy, so field writes are tracked.
*/
_state: LinkTopology
/** The graph this link is registered with in {@link useLinkStore}, if any. */
_graphId?: UUID
/** Link ID */
get id() {
return this._state.id
}
set id(value: LinkId) {
this._state.id = value
}
get type() {
return this._state.type
}
set type(value: ISlotType) {
this._state.type = value
}
id: LinkId
parentId?: RerouteId
type: ISlotType
/** Output node ID */
get origin_id() {
return this._state.originNodeId
}
set origin_id(value: NodeId) {
applyEndpointPatch(this, { originNodeId: value })
}
origin_id: NodeId
/** Output slot index */
get origin_slot() {
return this._state.originSlot
}
set origin_slot(value: number) {
applyEndpointPatch(this, { originSlot: value })
}
origin_slot: number
/** Input node ID */
get target_id() {
return this._state.targetNodeId
}
set target_id(value: NodeId) {
applyEndpointPatch(this, { targetNodeId: value })
}
target_id: NodeId
/** Input slot index */
get target_slot() {
return this._state.targetSlot
}
set target_slot(value: number) {
applyEndpointPatch(this, { targetSlot: value })
}
get parentId() {
return this._state.parentId
}
set parentId(value: RerouteId | undefined) {
this._state.parentId = value
}
target_slot: number
data?: number | string | boolean | { toToolTip?(): string }
_data?: unknown
@@ -238,15 +165,13 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
target_slot: number,
parentId?: RerouteId
) {
this._state = {
id,
type,
originNodeId: toNodeId(origin_id),
originSlot: origin_slot,
targetNodeId: toNodeId(target_id),
targetSlot: target_slot,
parentId
}
this.id = id
this.type = type
this.origin_id = toNodeId(origin_id)
this.origin_slot = origin_slot
this.target_id = toNodeId(target_id)
this.target_slot = target_slot
this.parentId = parentId
this._data = null
// center
@@ -537,15 +462,19 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
network.addFloatingLink(newLink)
}
network.links.delete(this.id)
unregisterLinkTopology(this)
layoutStore.deleteLinkLayout(this.id)
for (const reroute of reroutes) {
reroute.linkIds.delete(this.id)
if (!keepReroutes && !reroute.totalLinks) {
network._removeReroute(reroute.id)
network.reroutes.delete(reroute.id)
// Delete reroute from Layout Store
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.deleteReroute(reroute.id)
}
}
network.links.delete(this.id)
// Delete link from Layout Store
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.deleteLink(this.id)
}
/**
@@ -576,79 +505,3 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
return copy
}
}
/**
* Finds the floating links attached to a slot. A floating link has exactly
* one assigned endpoint, so its attachment is fully encoded in its own
* origin/target fields; nothing is stored on the slot.
* @param network The network whose floating links to search
* @param side Which side of the slot's node the links attach to
* @param nodeId The node (or subgraph IO node id) owning the slot
* @param slot The slot index
*/
export function slotFloatingLinks(
network: Pick<ReadonlyLinkNetwork, 'floatingLinks'>,
side: 'input' | 'output',
nodeId: NodeId,
slot: number
): LLink[] {
const result: LLink[] = []
for (const link of network.floatingLinks.values()) {
const attached =
side === 'input'
? link.target_id === nodeId && link.target_slot === slot
: link.origin_id === nodeId && link.origin_slot === slot
if (attached) result.push(link)
}
return result
}
/**
* Registers a link's topology into {@link useLinkStore} and adopts the
* store's reactive proxy as {@link LLink._state}, so the store and the link
* always agree and field writes are tracked. Call this at every site that
* adds a link to a graph's link map (or floating link map).
*
* {@link LLink._graphId} is only set when the store keeps this link's state:
* a link that loses a first-wins id collision stays detached, so its writes
* and removal cannot corrupt the winner's registration.
* @param graph The graph (or subgraph) the link belongs to
* @param link The link to register
*/
export function registerLinkTopology(
graph: Pick<LGraph, 'rootGraph'>,
link: LLink
): void {
if (link.id === toLinkId(-1)) return // transient toFloating clone
const graphId = graph.rootGraph.id
const registered = useLinkStore().registerLink(graphId, link._state)
if (registered) {
link._state = registered
link._graphId = graphId
}
}
/**
* Removes a link's topology from {@link useLinkStore} and detaches the link.
* No-op for links that never won registration ({@link LLink._graphId} unset),
* so a first-wins collision loser cannot remove the winner's entry.
* @param link The link to unregister
*/
export function unregisterLinkTopology(link: LLink): void {
if (!link._graphId) return
useLinkStore().deleteLink(link._graphId, link._state)
link._graphId = undefined
}
/**
* Unregisters every link and floating link a graph owns. Used when a graph's
* links leave the store without a whole-bucket wipe: subgraph-definition
* removal, and clearing a graph that shares its bucket with other graphs.
* @param graph The graph whose links should be unregistered
*/
export function unregisterAllLinkTopologies(
graph: Pick<LGraph, 'links' | 'floatingLinks'>
): void {
for (const link of graph.links.values()) unregisterLinkTopology(link)
for (const link of graph.floatingLinks.values()) unregisterLinkTopology(link)
}

View File

@@ -1,236 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { computed } from 'vue'
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useRerouteStore } from '@/stores/rerouteStore'
import { toRerouteId } from '@/types/rerouteId'
import { duplicateSubgraphNodeIds } from './__fixtures__/duplicateSubgraphNodeIds'
function connectedGraph() {
const graph = new LGraph()
const a = new LGraphNode('A')
const b = new LGraphNode('B')
a.addOutput('out', 'INT')
b.addInput('in', 'INT')
graph.add(a)
graph.add(b)
const link = a.connect(0, b, 0)!
return { graph, a, b, link }
}
describe('Reroute ↔ rerouteStore integration', () => {
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
it('createReroute registers the chain, removeReroute unregisters it', () => {
const { graph, link } = connectedGraph()
const store = useRerouteStore()
const reroute = graph.createReroute([10, 10], link)!
expect(store.getReroute(graph.rootGraph.id, reroute.id)?.id).toBe(
reroute.id
)
graph.removeReroute(reroute.id)
expect(store.getReroute(graph.rootGraph.id, reroute.id)).toBeUndefined()
})
it('setReroute (deserialisation) registers the chain', () => {
const { graph } = connectedGraph()
const store = useRerouteStore()
const reroute = graph.setReroute({
id: toRerouteId(3),
parentId: undefined,
pos: [5, 5],
linkIds: []
})
expect(store.getReroute(graph.rootGraph.id, reroute.id)?.id).toBe(3)
})
it('class parentId writes are observable through the store query', () => {
const { graph, link } = connectedGraph()
const store = useRerouteStore()
const first = graph.createReroute([10, 10], link)!
const second = graph.createReroute([20, 20], first)!
const parentId = computed(
() => store.getReroute(graph.rootGraph.id, first.id)?.parentId
)
expect(parentId.value).toBe(second.id)
first.parentId = undefined
expect(parentId.value).toBeUndefined()
})
it('disconnect pruning an empty reroute unregisters it', () => {
const { graph, link } = connectedGraph()
const store = useRerouteStore()
const reroute = graph.setReroute({
id: toRerouteId(1),
parentId: undefined,
pos: [10, 10],
linkIds: [link.id]
})
link.parentId = reroute.id
link.disconnect(graph)
expect(graph.reroutes.size).toBe(0)
expect(store.getReroute(graph.rootGraph.id, reroute.id)).toBeUndefined()
})
it('clear() removes the graphs chains from the store', () => {
const { graph, link } = connectedGraph()
const store = useRerouteStore()
const reroute = graph.createReroute([10, 10], link)!
const graphId = graph.rootGraph.id
graph.clear()
expect(store.getReroute(graphId, reroute.id)).toBeUndefined()
})
it('deduplicates colliding subgraph reroute ids into one root bucket', () => {
LiteGraph.registerNodeType('dummy', LGraphNode)
const data = structuredClone(
duplicateSubgraphNodeIds
) as unknown as SerialisableGraph
const [a, b] = data.definitions!.subgraphs!
a.reroutes = [{ id: 1, pos: [0, 0], linkIds: [1] }]
a.links![0].parentId = toRerouteId(1)
b.reroutes = [{ id: 1, pos: [0, 0], linkIds: [2] }]
b.links![0].parentId = toRerouteId(1)
const graph = new LGraph(data)
const store = useRerouteStore()
const subgraphs = [...graph.subgraphs.values()]
const rerouteIds = subgraphs.map((sg) => [...sg.reroutes.keys()][0])
expect(new Set(rerouteIds).size).toBe(2)
for (const sg of subgraphs) {
const [reroute] = [...sg.reroutes.values()]
expect(store.getReroute(graph.rootGraph.id, reroute.id)?.id).toBe(
reroute.id
)
const [link] = [...sg._links.values()]
expect(link.parentId).toBe(reroute.id)
}
})
it('linkIds follows the chain without manual set maintenance', () => {
const { graph, link } = connectedGraph()
const reroute = graph.setReroute({
id: toRerouteId(1),
parentId: undefined,
pos: [10, 10],
linkIds: []
})
link.parentId = reroute.id
expect([...reroute.linkIds]).toEqual([link.id])
link.parentId = undefined
expect(reroute.linkIds.size).toBe(0)
})
it('parentId setter rejects a mutual-parent cycle', () => {
const { graph } = connectedGraph()
const first = graph.setReroute({
id: toRerouteId(1),
parentId: undefined,
pos: [10, 10],
linkIds: []
})
const second = graph.setReroute({
id: toRerouteId(2),
parentId: undefined,
pos: [20, 20],
linkIds: []
})
first.parentId = second.id
second.parentId = first.id
expect(second.parentId).toBeUndefined()
expect(first.getReroutes()).not.toBeNull()
})
it('parentId setter rejects extending a chain back onto its root', () => {
const { graph } = connectedGraph()
const a = graph.setReroute({
id: toRerouteId(1),
parentId: undefined,
pos: [0, 0],
linkIds: []
})
const b = graph.setReroute({
id: toRerouteId(2),
parentId: a.id,
pos: [0, 0],
linkIds: []
})
const c = graph.setReroute({
id: toRerouteId(3),
parentId: b.id,
pos: [0, 0],
linkIds: []
})
a.parentId = c.id
expect(a.parentId).toBeUndefined()
expect(c.getReroutes()).not.toBeNull()
})
it('snapToGrid mirrors the snapped position into the layout store', () => {
const { graph, link } = connectedGraph()
const reroute = graph.createReroute([12, 17], link)!
reroute.snapToGrid(10)
expect(layoutStore.getRerouteLayout(reroute.id)?.position).toEqual({
x: reroute.pos[0],
y: reroute.pos[1]
})
})
it('refuses parentId writes that would create a cycle, allows repair', () => {
const { graph, link } = connectedGraph()
const first = graph.createReroute([10, 10], link)!
const second = graph.createReroute([20, 20], first)!
expect(first.parentId).toBe(second.id)
second.parentId = first.id
expect(second.parentId).toBeUndefined()
second._chain.parentId = first.id
second.parentId = undefined
expect(second.parentId).toBeUndefined()
})
it('floating marker survives through the store state', () => {
const { graph, a, link } = connectedGraph()
const store = useRerouteStore()
const reroute = graph.createReroute([10, 10], link)!
a.disconnectOutput(0)
expect(reroute.floating).toEqual({ slotType: 'input' })
expect(store.getReroute(graph.rootGraph.id, reroute.id)?.floating).toEqual({
slotType: 'input'
})
})
})

View File

@@ -1,15 +1,10 @@
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { EMPTY_MEMBERSHIP, useRerouteStore } from '@/stores/rerouteStore'
import type { RerouteMembership } from '@/stores/rerouteStore'
import { UNASSIGNED_NODE_ID } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
import type { FloatingRerouteSlot, RerouteChain } from '@/types/rerouteChain'
import type { RerouteId } from '@/types/rerouteId'
import type { UUID } from '@/utils/uuid'
import { LayoutSource } from '@/renderer/core/layout/types'
import { LGraphBadge } from './LGraphBadge'
import type { LGraph } from './LGraph'
import type { LGraphNode } from './LGraphNode'
import { LLink } from './LLink'
import type { LinkId } from './LLink'
@@ -30,9 +25,14 @@ import type { Serialisable, SerialisableReroute } from './types/serialisation'
const layoutMutations = useLayoutMutations()
export type { FloatingRerouteSlot } from '@/types/rerouteChain'
export type { RerouteId } from '@/types/rerouteId'
/** The input or output slot that an incomplete reroute link is connected to. */
export interface FloatingRerouteSlot {
/** Floating connection to an input or output */
slotType: 'input' | 'output'
}
/**
* Represents an additional point on the graph that a link path will travel through. Used for visual organisation only.
*
@@ -59,52 +59,24 @@ export class Reroute
/** The network this reroute belongs to. Contains all valid links and reroutes. */
private readonly network: WeakRef<LinkNetwork>
/**
* The reroute's chain state. Once registered with {@link useRerouteStore},
* this is the store's reactive proxy, so field writes are tracked.
*/
_chain: RerouteChain
/** The graph this reroute is registered with in {@link useRerouteStore}, if any. */
_graphId?: UUID
private parentIdInternal?: RerouteId
public get parentId(): RerouteId | undefined {
return this._chain.parentId
return this.parentIdInternal
}
/** Ignores attempts to create an infinite loop. @inheritdoc */
public set parentId(value) {
if (value === this.id) return
if (value !== undefined && this.createsParentCycle(value)) return
this._chain.parentId = value
}
/** Walks the prospective parent chain from `value`, reporting whether it loops back to this reroute. */
private createsParentCycle(value: RerouteId): boolean {
const network = this.network.deref()
const visited = new Set<RerouteId>([this.id])
let nextId: RerouteId | undefined = value
while (nextId !== undefined) {
if (visited.has(nextId)) return true
visited.add(nextId)
nextId = network?.reroutes.get(nextId)?.parentId
}
return false
if (this.getReroutes() === null) return
this.parentIdInternal = value
}
public get parent(): Reroute | undefined {
return this.network.deref()?.getReroute(this._chain.parentId)
return this.network.deref()?.getReroute(this.parentIdInternal)
}
/** This property is only defined on the last reroute of a floating reroute chain (closest to input end). */
get floating(): FloatingRerouteSlot | undefined {
return this._chain.floating
}
set floating(value: FloatingRerouteSlot | undefined) {
this._chain.floating = value
}
floating?: FloatingRerouteSlot
private readonly posInternal: Point = [0, 0]
/** @inheritdoc */
@@ -148,24 +120,11 @@ export class Reroute
/** @inheritdoc */
selected?: boolean
private get membership(): RerouteMembership {
return this._graphId
? useRerouteStore().getMembership(this._graphId, this.id)
: EMPTY_MEMBERSHIP
}
/**
* The ID ({@link LLink.id}) of every link using this reroute.
* Derived from the links' parentId chains; never stored.
*/
get linkIds(): ReadonlySet<LinkId> {
return this.membership.linkIds
}
/** The ID ({@link LLink.id}) of every link using this reroute */
linkIds: Set<LinkId>
/** The ID ({@link LLink.id}) of every floating link using this reroute */
get floatingLinkIds(): ReadonlySet<LinkId> {
return this.membership.floatingLinkIds
}
floatingLinkIds: Set<LinkId>
/** Cached cos */
cos: number = 0
@@ -242,18 +201,61 @@ export class Reroute
* @param id Unique identifier for this reroute
* @param network The network of links this reroute belongs to. Internally converted to a WeakRef.
* @param pos Position in graph coordinates
* @param linkIds Link IDs ({@link LLink.id}) of all links that use this reroute
*/
constructor(
id: RerouteId,
network: LinkNetwork,
pos?: Point,
parentId?: RerouteId
parentId?: RerouteId,
linkIds?: Iterable<LinkId>,
floatingLinkIds?: Iterable<LinkId>
) {
this.id = id
this.network = new WeakRef(network)
this._chain = { id }
this.parentId = parentId
if (pos) this.pos = pos
this.linkIds = new Set(linkIds)
this.floatingLinkIds = new Set(floatingLinkIds)
}
/**
* Applies a new parentId to the reroute, and optinoally a new position and linkId.
* Primarily used for deserialisation.
* @param parentId The ID of the reroute prior to this reroute, or
* `undefined` if it is the first reroute connected to a nodes output
* @param pos The position of this reroute
* @param linkIds All link IDs that pass through this reroute
*/
update(
parentId: RerouteId | undefined,
pos?: Point,
linkIds?: Iterable<LinkId>,
floating?: FloatingRerouteSlot
): void {
this.parentId = parentId
if (pos) this.pos = pos
if (linkIds) this.linkIds = new Set(linkIds)
this.floating = floating
}
/**
* Validates the linkIds this reroute has. Removes broken links.
* @param links Collection of valid links
* @returns true if any links remain after validation
*/
validateLinks(
links: ReadonlyMap<LinkId, LLink>,
floatingLinks: ReadonlyMap<LinkId, LLink>
): boolean {
const { linkIds, floatingLinkIds } = this
for (const linkId of linkIds) {
if (!links.has(linkId)) linkIds.delete(linkId)
}
for (const linkId of floatingLinkIds) {
if (!floatingLinks.has(linkId)) floatingLinkIds.delete(linkId)
}
return linkIds.size > 0 || floatingLinkIds.size > 0
}
/**
@@ -266,15 +268,15 @@ export class Reroute
*/
getReroutes(visited = new Set<Reroute>()): Reroute[] | null {
// No parentId - last in the chain
if (this._chain.parentId === undefined) return [this]
if (this.parentIdInternal === undefined) return [this]
// Invalid chain - looped
if (visited.has(this)) return null
visited.add(this)
const parent = this.network.deref()?.reroutes.get(this._chain.parentId)
const parent = this.network.deref()?.reroutes.get(this.parentIdInternal)
// Invalid parent (or network) - drop silently to recover
if (!parent) {
this._chain.parentId = undefined
this.parentIdInternal = undefined
return [this]
}
@@ -293,14 +295,14 @@ export class Reroute
withParentId: RerouteId,
visited = new Set<Reroute>()
): Reroute | null | undefined {
if (this._chain.parentId === withParentId) return this
if (this.parentIdInternal === withParentId) return this
if (visited.has(this)) return null
visited.add(this)
if (this._chain.parentId === undefined) return
if (this.parentIdInternal === undefined) return
return this.network
.deref()
?.reroutes.get(this._chain.parentId)
?.reroutes.get(this.parentIdInternal)
?.findNextReroute(withParentId, visited)
}
@@ -384,14 +386,31 @@ export class Reroute
/**
* Changes the origin node/output of all floating links that pass through this reroute.
* @param node The new origin node
* @param index The slot index of the new origin output
* @param output The new origin output slot
* @param index The slot index of {@link output}
*/
setFloatingLinkOrigin(node: LGraphNode, index: number) {
setFloatingLinkOrigin(
node: LGraphNode,
output: INodeOutputSlot,
index: number
) {
const network = this.network.deref()
const floatingOutLinks = this.getFloatingLinks('output')
if (!floatingOutLinks)
throw new Error('[setFloatingLinkOrigin]: Invalid network.')
if (!floatingOutLinks.length) return
output._floatingLinks ??= new Set()
for (const link of floatingOutLinks) {
// Update cached floating links
output._floatingLinks.add(link)
network
?.getNodeById(link.origin_id)
?.outputs[link.origin_slot]?._floatingLinks?.delete(link)
// Update the floating link
link.origin_id = node.id
link.origin_slot = index
}
@@ -418,22 +437,47 @@ export class Reroute
const offsetY = LiteGraph.NODE_SLOT_HEIGHT * 0.7
const { pos } = this
const previousPos = { x: pos[0], y: pos[1] }
pos[0] = snapTo * Math.round(pos[0] / snapTo)
pos[1] = snapTo * Math.round((pos[1] - offsetY) / snapTo) + offsetY
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.moveReroute(this.id, { x: pos[0], y: pos[1] }, previousPos)
return true
}
removeAllFloatingLinks() {
for (const linkId of this.floatingLinkIds) {
this.removeFloatingLink(linkId)
}
}
removeFloatingLink(linkId: LinkId) {
const network = this.network.deref()
if (!network) return
for (const linkId of [...this.floatingLinkIds]) {
const floatingLink = network.floatingLinks.get(linkId)
if (floatingLink) network.removeFloatingLink(floatingLink)
const floatingLink = network.floatingLinks.get(linkId)
if (!floatingLink) {
console.warn(
`[Reroute.removeFloatingLink] Floating link not found: ${linkId}, ignoring and discarding ID.`
)
this.floatingLinkIds.delete(linkId)
return
}
network.removeFloatingLink(floatingLink)
}
/**
* Removes a link or floating link from this reroute, by matching link object instance equality.
* @param link The link to remove.
* @remarks Does not remove the link from the network.
*/
removeLink(link: LLink) {
const network = this.network.deref()
if (!network) return
const floatingLink = network.floatingLinks.get(link.id)
if (link === floatingLink) {
this.floatingLinkIds.delete(link.id)
} else {
this.linkIds.delete(link.id)
}
}
@@ -644,7 +688,7 @@ export class Reroute
id,
parentId,
pos: [pos[0], pos[1]],
linkIds: [...linkIds].sort((a, b) => a - b),
linkIds: [...linkIds],
floating: this.floating ? { slotType: this.floating.slotType } : undefined
}
}
@@ -778,68 +822,3 @@ function getNextPos(
function getDirection(fromPos: Point, toPos: Point) {
return Math.atan2(toPos[1] - fromPos[1], toPos[0] - fromPos[0])
}
/**
* Marks a link's reroute chain as no longer floating: clears each reroute's
* floating marker and drag state, and removes any floating link that
* terminates at the chain's last reroute. Call when a real link connects
* through the chain.
* @param network The network containing the chain
* @param link The link whose chain was just connected
*/
export function anchorRerouteChain(network: LinkNetwork, link: LLink): void {
const reroutes = LLink.getReroutes(network, link)
for (const reroute of reroutes) {
reroute.floating = undefined
reroute._dragging = undefined
}
const lastReroute = reroutes.at(-1)
if (!lastReroute) return
for (const linkId of lastReroute.floatingLinkIds) {
const floatingLink = network.floatingLinks.get(linkId)
if (floatingLink?.parentId === lastReroute.id) {
network.removeFloatingLink(floatingLink)
}
}
}
/**
* Registers a reroute's chain state into {@link useRerouteStore} and adopts
* the store's reactive proxy as {@link Reroute._chain}, so the store and the
* reroute always agree and field writes are tracked. Call this at every
* site that adds a reroute to a graph's reroute map.
* @param graph The graph (or subgraph) the reroute belongs to
* @param reroute The reroute to register
*/
export function registerRerouteChain(
graph: Pick<LGraph, 'rootGraph'>,
reroute: Reroute
): void {
const graphId = graph.rootGraph.id
reroute._chain = useRerouteStore().registerReroute(graphId, reroute._chain)
reroute._graphId = graphId
}
/**
* Removes a reroute's chain state from {@link useRerouteStore} and detaches
* the reroute. No-op for reroutes that were never registered.
* @param reroute The reroute to unregister
*/
export function unregisterRerouteChain(reroute: Reroute): void {
if (!reroute._graphId) return
useRerouteStore().deleteReroute(reroute._graphId, reroute._chain)
reroute._graphId = undefined
}
/**
* Unregisters every reroute a graph owns. Used when a graph's reroutes
* leave the store without a whole-bucket wipe: subgraph-definition removal,
* and clearing a graph that shares its bucket with other graphs.
* @param graph The graph whose reroutes should be unregistered
*/
export function unregisterAllRerouteChains(
graph: Pick<LGraph, 'reroutes'>
): void {
for (const reroute of graph.reroutes.values()) unregisterRerouteChain(reroute)
}

Some files were not shown because too many files have changed in this diff Show More