Per D-no-node-widget-access (2026-05-19, bilateral A1 closure), nodes
can no longer enumerate widgets — `node.getWidgets()` was removed
from NodeHandle. The previous dynamicPrompts.v2 implementation
iterated node widgets to attach per-widget `beforeSerialize`
handlers; that pattern is now forbidden.
Stubbed pending a follow-up public API (`defineWidgetAugmenter` or
per-widget `setup` on `defineWidget`) that lets extensions attach
behavior to existing widget types matching a predicate. v1
(`Comfy.DynamicPrompts`) continues to work — this is a v2 surface
gap only, not a user-visible regression.
Refs: decisions/D-no-node-widget-access.md
Strangler-pattern v2 example demonstrating the single-coordinate-space policy from D-coord-space (ACCEPTED 2026-05-18, option iii) and Axiom A13 (Single Coordinate Space — Canvas).
Three sections: (1) default path — every NodeHandle spatial accessor speaks canvas units; (2) escape-hatch path — globalThis.app.canvas.{ds,canvas} + window.devicePixelRatio with the required '// escape-hatch — see D-coord-space.md' annotation comment on every use site; (3) cliff documentation — what's NOT on the public surface (no getScreenPosition, no space param, no branded ClientPoint type).
Serves as the executable canary that the documented pattern actually works. Authors and AI agents reading the v2 extensions/core/*.v2.ts directory see the policy demonstrated alongside the existing dynamicPrompts/previewAny/imageCrop/noteNode/slotDefaults/rerouteNode/webcamCapture examples.
knip.config.ts: add new file to strangler ignore list. Phase A gates: format clean / lint 0 errors 3 pre-existing warnings / knip 6 pre-existing tag hints + 1 pre-existing config hint.
Strangler-pattern port of the WEBCAM custom widget type from the v1 webcamCapture extension to the v2 mount-lifecycle seam (Axiom A12 / D-widget-converge §Decision).
- Registers WEBCAM via defineWidget({type:'WEBCAM',mount}). The mount body constructs the <video> + container, captures them via closure (no widget.element accessor exposed per A12), and returns a cleanup that stops MediaStream tracks on widget destruction. - Uses ctx.onAfterRemount to re-attach the cached container when the host is swapped (graph ↔ app mode swap, subgraph promotion) per D-widget-converge §Clarification 1 — mount body is NOT re-invoked. - Companion defineNode stays a placeholder: GAP-2 (no type-construction addWidget('button',…)) and GAP-11 (no async setSerializedValue path; v2's WidgetBeforeSerializeEvent doesn't yet promise async resolution) block the full node-side port. v1 webcamCapture.ts remains authoritative until those gaps close.
knip.config.ts: add the new file to ignore list (matches existing v2 strangler entries; not wired into bootstrap).
Phase A gates: lint 0 errors / 3 pre-existing warnings; format:check clean; knip 6 pre-existing tag hints + 1 pre-existing config hint / 0 new failures.
F-12144-2: rewrite shell.ts header to say 're-exported from' (matches the
re-export code; the original 'moved from' wording was ambiguous).
F-12144-3: tag SlotDefaults v2 extension as '(DEMO — incomplete migration)'
and add a JSDoc @remarks block listing the v1 features (beforeRegisterNodeDef
node metadata, settings-dialog contribution, slot-type registry mutation)
that remain TBD. Intentionally NOT porting them — staging-demo intent per the
GAP-4/5/6 feedback to Simon/Austin.
F-12144-4: drop the redundant 'as string' cast in dynamicPrompts.v2; the
immediate 'typeof value === "string"' check makes the cast self-contradicting.
Defensive runtime check stays.
F-12144-10: add a 3-line @example to defineWidgetExtension JSDoc to match the
defineNodeExtension/defineExtension docs.
F-12144-11: reorder DOMWidgetOptions and NodeHandle JSDoc blocks so each
docblock immediately precedes its declaration.
Per D9 strangler-bridge taxonomy, every direct globalThis.app access in
v2 conversion files must carry a 'strangler-bridge:Phase-B' marker so
the bridge audit can enumerate remaining touch-points before Phase-B
removes the global.
Tag the three sites in rerouteNode.v2.ts (configuringGraph guard in
onConnectionsChange, configuringGraph guard in updateLink, and the
canvas.setDirty call from the context-menu callback).
Known-stubbed channels (executed/connected/disconnected/configured)
dispatch to a Phase-A stub bus that does not deliver events. Previously
these registrations were silent — extensions saw no warning that their
handlers were dead until #11939 lands the dispatch substrate.
Add a DEV console.warn (eslint-disable-next-line no-console, matching
the established pattern in this file) for each known-stubbed channel
registration so authors know to wait on #11939.
Restratified i-ext. Adds v2 conversions for 6 core extensions:
- dynamicPrompts.v2.ts
- imageCrop.v2.ts
- previewAny.v2.ts
- noteNode.v2.ts
- rerouteNode.v2.ts
- slotDefaults.v2.ts
And registers the first 3 in src/extensions/core/index.ts.
Note: noteNode.v2, rerouteNode.v2, slotDefaults.v2 are NOT yet
registered in index.ts (pre-existing issue from original i-ext branch).
Filed as a follow-up TODO.
Original (pre-restratify) branch tip backed up at
refs/backup/restratify-20260511/ext-api-i-ext.
Hand-patched mirror of foundation hygiene cleanup per AGENTS.md Rule 8
(Node 20 vs vite blocks `pnpm build` in this worktree):
- Strip HR-style section dividers
- Strip parenthetical decision archaeology
((per D5), (D-immutability-enforcement, Hybrid C),
D-bootstrap-hooks (W6.P6.C), D-shell-ui-entrypoints (W6.P5.C), etc.)
from per-export JSDoc — preserve AXIOMS.md §A1/A14/A15/A16/A12 refs
and PHASE_A_EXCLUDED axiom blocks (functional content).
- Strip "W6.P8.UNMIGRATABLE / D-input-output-shape" todo refs.
- One stale `defineWidgetExtension` JSDoc example fixed (was already
on foundation; rebase carried the fix through).
Mirrors foundation commit `ee0537fdb5`.
ADR: decisions/D-design-review-hygiene-cleanup.md
Zero published-surface change (no types added/removed/renamed).
Hand-patched packages/extension-api/build/index.d.ts to match the
foundation surface after A16 closure:
- Drop `isSerializeEnabled` / `setSerializeEnabled` from WidgetHandle
- Drop `WidgetBeforeSerializeEvent.context` 4-way discriminator
- Drop `WidgetBeforeSerializeEvent.skip()`
- Drop `on('propertyChange', handler)` overload
- Drop entire `WidgetPropertyChangeEvent` interface
- Drop `WidgetOptions.serialize?: boolean` key
- Update JSDoc on `WidgetBeforeSerializeEvent` / `options` / `serializeValue`
to reference A16 / drop transport-discriminator examples
`NodeBeforeSerializeEvent.context` is intentionally retained — the
node-level surface stays `@deprecated` + runtime-warned per ADR-0010
(not banned by D-widget-serialization-simplification).
Hand-edit pattern because pnpm build is blocked by Node 20 vs vite
requirement per AGENTS.md Rule 8. Surface verified to match foundation
HEAD e56187adf3.
Refs: decisions/D-widget-serialization-simplification.md, AXIOMS.md §A15+A16
Picks up the canvas-units / escape-hatch / A13 JSDoc on NodeHandle.{getPosition,setPosition,getSize,setSize} from foundation fa3229b402 so the published .d.ts and the docgen pipeline see the policy text. No symbol changes.
Cascade of D20 (decisions/D20-id-type-convergence.md) from foundation:
- Rebuild packages/extension-api/build/index.d.ts via vite build so the
published .d.ts ships the new id/equals surface and drops the three
demoted brand re-exports.
- src/types/extensionV2.ts: stop re-exporting NodeEntityId, WidgetEntityId,
SlotEntityId — they are no longer in the @/extension-api barrel per D20.
Use node.id/widget.id (string) and equals(other) for the public surface.
The packages/extension-api/build/ directory contains TypeDoc/tsc-generated
.d.ts snapshots committed for reviewability (per D17). These should not
be format-checked since they're machine-generated.
Adds packages/extension-api/build/** to oxfmt ignorePatterns so that
'pnpm format:check' passes cleanly post-restratify reconciliation.
Part of RECONCILE-PKG (PR #12143).
Remove index.js and index.js.map from git tracking. The .d.ts file is
committed for npm package visibility and reviewer inspection, while JS
files are generated at build time and don't need to be in version control.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Remove build/ from .gitignore so types are visible in PR
- Update vite config to use rollupTypes for bundled declarations
- Update package.json types path to ./build/index.d.ts
- Exclude packages/*/build/ from lint-staged
- Include index.d.ts (37KB bundled types), index.js (2MB), index.js.map
The bundled index.d.ts contains all public API types inline.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Resolves CodeRabbit findings F-12143-1, F-12143-3, F-12143-7.
Per D17-pkg2-build-strategy ADR: package now builds via Vite library
mode + vite-plugin-dts, dropping the inconsistent rootDir restriction
and the --emitDeclarationOnly flag. Vite resolves @/* aliases against
the canonical surface in main app src/extension-api/, externalizes vue
as a peer dep, and bundles to a single 3.95 kB build/index.js.
vite-plugin-dts emits the per-file .d.ts closure under build/ with
@/* imports rewritten to relative paths.
The original tsc-only plan in #12143 was unworkable because tsc does
not rewrite path aliases — emitted JS would have unresolvable @/...
imports at runtime. Vite library mode is the smallest deviation that
satisfies PKG3 acceptance ('emits BOTH .d.ts + .js') while preserving
'the barrel is the source of truth in main app src/extension-api/'
intent in packages/extension-api/AGENTS.md.
Verified:
pnpm build emits build/index.js (3.95 kB) + build/.../*.d.ts tree
node -e "import('./build/index.js')" returns real functions for
defineNodeExtension, defineExtension, onNodeMounted, onNodeRemoved
synthetic downstream consumer importing NodeHandle/WidgetHandle
types and runtime fns type-checks under tsc --noEmit
pnpm lint && pnpm format:check && pnpm knip pass
Also adds packages/extension-api/vite.config.mts to the eslint
allowDefaultProject list so the typed lint rules can resolve it.
See decisions/D17-pkg2-build-strategy.md in the cross-repo workspace
for the full options analysis (A: types-only — rejected; B: vendor
sources — deferred to post-#11939; C: build from main app sources —
chosen).
The dry_run input was declared as type: boolean but the default was the
string 'true' and the conditional comparisons used '== "true"' / '== "false"'.
GitHub Actions coerces typed boolean inputs to actual booleans, so the
string comparisons would never match a workflow_dispatch invocation.
- default: 'true' → default: true (match declared type)
- inputs.dry_run == 'false' → !inputs.dry_run
- inputs.dry_run == 'true' → inputs.dry_run
Validated locally with actionlint (clean).
Refs CodeRabbit findings F-12143-8, F-12143-9, F-12143-10 in
research/reviews/coderabbit-batch-2026-05-12.md.
oxlint flags console.log in packages/extension-api/scripts/build-docs.ts
(intentional CLI progress output). The pre-commit lint-staged hook in
ci-lint-format then aborts before the [automated] format commit can be
pushed back, blocking the entire lint-and-format job.
- Add file-level eslint-disable for no-console (CLI build script)
- Whitelist build-docs.ts in eslint.config.ts allowDefaultProject so
typed-lint does not error on the file outside the tsconfig project graph
Discovered while rebasing #12143 onto the foundation-v2 knip fix
(5b05f2b793).
The package barrel at packages/extension-api/src/index.ts duplicated the
canonical surface in src/extension-api/index.ts. Two hand-maintained
exports lists drift out of sync — Architect #4's removal of
startExtensionSystem from the public surface, for example, only landed in
the canonical barrel.
Collapse the package barrel to a single 'export * from
@/extension-api/index' re-export so the canonical file is the one source of
truth. Future surface additions/removals flow through automatically; no
risk of asymmetric export sets between the in-tree consumer
(@/extension-api) and the npm package consumer
(@comfyorg/extension-api).
Restratified i-pkg. Adds the @comfyorg/extension-api npm package
(packages/extension-api/) — package.json, tsconfig.{json,build,docs},
typedoc.json, scripts/build-docs.ts, README, .npmignore — plus three
CI workflows (extension-api-typecheck.yml, extension-api-publish.yml,
ci-tests-extension-api.yaml) and the docgen helper script.
Modifies foundation's src/extension-api/index.ts and
src/services/extension-api-service.ts with the package-aware tweaks
required for npm consumption.
Lockfile regenerated to include packages/extension-api workspace deps.
Original (pre-restratify) branch tip backed up at
refs/backup/restratify-20260511/ext-api-i-pkg.
typedoc renders re-exported type pages from the original-declaration
site (src/types/extensionTypes.ts), not from the @comfyorg/extension-api
barrel re-export. The shell-UI type aliases (ToastMessageOptions,
HotkeyExtension, AboutBadgeExtension, ToolbarButtonExtension) therefore
shipped to the dashboard without any @example blocks even though their
matching defineX entry points carry full examples in registrations.ts /
imperatives.ts.
Lift the canonical @example from each defineX onto the source
declaration so the dashboard type page renders with a working snippet.
Pure JSDoc — zero exported-type diff. Phase A freeze respected.
Wave-18 W18.J. Closes W17.N.2.
- W17.C: refresh 5 stale @example blocks (types.ts, lifecycle.ts, node.ts,
imperatives.ts ×2) — replace deprecated `async setup()` with
`setup() { onMounted(...) }`; remove A1-removed `node.getWidget` from
defineNode + NodeHandle.on migration examples; align imperatives examples
with W17.B notify-toast verdict.
- W17.D: author ~37 new @example blocks across 9 files for sparse exports
surfaced by W17.A audit. Pure JSDoc — zero exported-type diff.
- W17.E: add @deprecated JSDoc to notify() + NotifyOptions per
D-notify-toast-consolidation (W17.B verdict (a) soft-deprecate).
Pure JSDoc — no signature, type, or export changes.
Phase A surface remains FROZEN at ee0537fdb5 (foundation surface SHA
unchanged; only @example / @deprecated text differs).
See research/dashboard-snippet-audit-2026-05-21.md (W17.A) for full
per-edit rationale.
Address PR #12142 review thread PRRT_kwDOMIrOxs6BQjVr —
remove '(I-SR.3 / MIG1.E5)' internal task-tracker reference from the
startExtensionSystem() comment. Wave-11 hygiene cleanup did the same
across src/extension-api/**; this completes the same hygiene for the
single call site in src/scripts/app.ts.
Review: https://github.com/Comfy-Org/ComfyUI_frontend/pull/12142#discussion_r3222947433
Wave-10 follow-up to D-widget-serialization-simplification (wave-9). Closes
the v1 enforcement loop on AXIOMS.md A15 (Widget Declarativity).
User direction 2026-05-20: "we dont need soft deprecate for beforeSerialize
OR addWidget — they just simply shouldnt be present in the new API. for the
old api surfaces, we can just add deprecations with the suggested
remediation."
## v2 surface — absent (no soft-deprecate)
- Delete dead `addWidget` + `addDOMWidget` shims in
`src/services/extension-api-service.ts` (~30 lines), along with the
`domWidgetElements` side-table and `getDOMWidgetElement` accessor.
With the public NodeHandle no longer exposing these methods, all of
this is unreachable.
- Drop `DOMWidgetOptions` import from the service.
- Scrub 5 v2 JSDoc `@example` blocks that still referenced
`node.addWidget(...)` as if it were a valid call:
node.ts (NodeBeforeSerialize migration example), widget.ts (3 spots:
name doc, label doc, WidgetOptions doc), types.ts (defineWidget doc),
lifecycle.ts (defineNode example).
- Replace each example with one of A15's three remediation paths
(boxed widget / non-widget UI primitive / schema input).
## v1 surface — @deprecated + once-per-session console.warn
- Add @deprecated JSDoc + `warnDeprecated` (session-deduped via the
existing `./utils/feedback` mechanism) on:
- `LGraphNode.addWidget` (LGraphNode.ts)
- `LGraphNode.addCustomWidget` (same file)
- `LGraphNode.prototype.addDOMWidget` (src/scripts/domWidget.ts)
- Shared message constant `ADD_WIDGET_DEPRECATION_MESSAGE` so all three
warn paths dedupe to one console line per session. Message text cites
AXIOMS.md A15 + decisions/D-ban-runtime-addwidget.md and enumerates
the three migration paths.
## First-party core call sites — silenced via sentinel
- New `__suppressDeprecationWarning?: boolean` field on
`IWidgetOptions` (marked @internal, NOT part of public surface).
- Added to all first-party call sites that haven't yet migrated:
src/scripts/widgets.ts (valueControl, comboFilter),
src/extensions/core/uploadAudio.ts (4 sites),
src/extensions/core/load3d.ts (3 sites),
src/extensions/core/webcamCapture.ts (2 sites),
src/extensions/core/customWidgets.ts (1 site).
- `src/scripts/domWidget.ts addWidget()` helper (internal renderer
registration path) auto-injects the sentinel before delegating to
`addCustomWidget` — third-party callers of LGraphNode.addCustomWidget
still see the warning.
- Migration of these first-party sites tracked as wave-9 follow-up
#2 (preview/progress/audio widgets migration audit).
## Axiom-Excluded Test Annotation Policy — new, codified
- AXIOMS.md: new "Axiom-Excluded Test Annotation Policy" section after
A14, retiring the "compat-floor blast_radius ≥ N MUST pass" doctrine.
- AGENTS.md Rule 11: tests asserting on axiom-excluded surfaces MUST
use `axiomExcluded({...})` (vitest test.fails + task.meta.annotations)
rather than deletion. Deletion only when the *scenario* is no longer
meaningful.
- decisions/D-ban-runtime-addwidget.md: "Testing policy" section
documenting concrete application to bc-05 / bc-11 BC tests in tf
branch (deferred to tf cascade step).
## Decision propagation
- (a) commit on PR branches — this commit (foundation) + cascade
- (b) ADR — `decisions/D-ban-runtime-addwidget.md` ACCEPTED 2026-05-20
- (c) dashboard MDX — refresh via scripts/refresh-api-docs.sh after
cascade
## Validation
- scripts/check-axiom-compliance.sh: all 7 checks pass (no new strict
patterns added; A15 already strict-fails on v2 re-introduction per
wave-9, and the v1 deprecation is enforced at the call-site level
via the warnDeprecated mechanism)
- oxlint: 0 warnings/errors on all 13 touched files
- oxfmt: applied to all 13 touched files
- tsc: no new errors introduced (pre-existing TS2305/TS2353/TS2307 errors
from prior waves remain under AGENTS.md Rule 8 carve-out)
- Bundle size shrinks by ~30 lines (dead v2 shims removed)
## Compatibility (D6 parallel-paths)
- v1 path remains fully functional. Deprecation is a soft signal — no
throw, no exception, no behavior change beyond one console.warn per
session.
- Existing custom-node packs (R7 sweep: 5+ evidenced) continue to work;
authors see migration text on first addWidget call.
Refs: AXIOMS.md A15, A16; D-widget-serialization-simplification (wave-9);
ADR-0010 (node-level beforeSerialize deprecation pattern); D6
parallel-paths.
Codify A15 (Widget Declarativity) + A16 (Unified Serialization Target) by
collapsing widget-level serialization to its irreducible core.
Per D-widget-serialization-simplification (wave-9, 2026-05-20) — comment out
in src/extension-api/widget.ts (A14 pattern):
- WidgetHandle.setSerializeEnabled(enabled) / .isSerializeEnabled()
- WidgetBeforeSerializeEvent.context: 'workflow'|'prompt'|'clone'|'subgraph-promote'
- WidgetBeforeSerializeEvent.skip()
- WidgetPropertyChangeEvent (vacuous union after 'serialize' removed)
- WidgetHandle.on('propertyChange', handler) overload
- WidgetOptions.serialize?: boolean key
Per A16, the sole extension-author interface to serialization is
`widget.on('beforeSerialize', handler)`. The framework writes one payload
to every transport (workflow JSON, API prompt, clone, subgraph-promote);
authors do not branch on transport and cannot disable serialization. Per
A15, runtime widget addition was the underlying reason for the disable
matrix — declarativity removes the cause, A16 removes the escape hatch.
Removed framework-side adapters in src/services/extension-api-service.ts:
isSerializeEnabled / setSerializeEnabled blocks (lines 302-315) and the
propertyChange branch of the event dispatcher (line 362). Dropped
WidgetComponentSerialize import (now unused).
Removed WidgetPropertyChangeEvent from src/extension-api/index.ts barrel.
Updated JSDoc: stripped 4-context narrative + skip() examples from
WidgetBeforeSerializeEvent, dropped widget.options.serialize references
from the options accessor, dropped e.context === 'prompt' from the
serializeValue accessor example.
node.on('beforeSerialize') (separate surface) remains `@deprecated` +
runtime-warned per ADR-0010 — NOT banned by this commit (per user
direction 2026-05-20).
Blast radius: 0 v2 ext call sites for any removed surface (verified via
rg sweep across restack-ext-v2/src and foundation extensions/core).
v1 path untouched per D6 parallel-paths migration.
Refs:
- decisions/D-widget-serialization-simplification.md (ACCEPTED 2026-05-20)
- AXIOMS.md §A15 (Widget Declarativity), §A16 (Unified Serialization Target)
- AXIOMS.md §A14 (7 new rows in COMMENTED OUT table)
- D14-serialization-convergence.md (predecessor — promised this end-state)
- D-no-node-widget-access.md (wave-8, structural sibling)
- design-review-12142.md row #11
Verified: scripts/check-axiom-compliance.sh ✅ all checks pass
(A1, A2, A3, A4, A6, A15, A16 — strict-fail on re-introduction)
Comments out NodeHandle.getWidget(name) + NodeHandle.getWidgets() per
D-no-node-widget-access (ACCEPTED 2026-05-19, ADR landed). Closes the
node→widget direction so that the v2 public surface enforces A1 as
written: 'nodes cannot enumerate widgets' is now bilateral with
'nodes cannot reference widgets' — the widget→node direction
(widget.parentNode) remains the sole node↔widget channel.
Origin: a contradiction was surfaced during handoff-14 (wave-7
post-cascade). AXIOMS.md §A1 is NON-NEGOTIABLE and says 'Node hooks
cannot reference/modify widgets' + 'Widgets can know their parent
node, but nodes cannot enumerate widgets'. However design-review-12142
decision #13 (2026-05-12, 'getWidget seems good') had renamed
widget() → getWidget() without re-checking A1, and a codified
read-only carve-out in scripts/check-axiom-compliance.sh:22 papered
over the gap. The carve-out is removed; the axiom checker is now
strict-fail on any node→widget surface.
Changes:
- node.ts: getWidget(name) + getWidgets() commented out with A1+A2
rationale block (A2 pattern: 'comment out if not 100% sure')
- widget.ts: 2 JSDoc examples rewritten to use
defineWidget({mount}, ctx.widget) — the only legal v2 path to a
WidgetHandle
Migration cost (v2 surface):
- 0 call sites in the tf test suite
- 1 call site in ext-v2 (extensions/core/dynamicPrompts.v2.ts) —
stubbed pending follow-up 'defineWidgetAugmenter' API; v1 path
(Comfy.DynamicPrompts) continues to work
- TypeScript-only signature change; no runtime change
Restoration criteria: see AXIOMS.md §A14 entry — a documented use
case unexpressible via widget.parentNode + defineWidget({mount}) +
provide/inject, A1 re-audit pass, and bc-XX test triplet coverage
across promotion / app-mode / linked-instance scenarios.
Refs:
- ADR: decisions/D-no-node-widget-access.md (ACCEPTED 2026-05-19)
- AXIOMS.md §A1 + §A14
- scripts/check-axiom-compliance.sh (strict bilateral check)
Adopt per-surface defineX entries plus inline-imperative carve-out for
toast + notify, per D-shell-ui-entrypoints ADR (ACCEPTED 2026-05-18,
foundation impl handoff-13).
## What lands
New shell-UI registration entries (registrations.ts, NEW):
- defineSidebarTab / defineBottomPanelTab — wire to workspace stores
- defineCommand — wires to commandStore.registerCommand
- defineHotkey — parses key combo, wires to keybindingStore
- defineSetting — wires to settingStore.addSetting
- defineAboutBadge / defineToolbarButton — module-level registries
(consumed by aboutPanelStore / action-bar component via P5.E follow-up)
All defineX return DisposableHandle { dispose(): void }. Lazy-mount
queue (pendingEntries[]) flushed by startExtensionSystem() via the new
_flushShellRegistrations() hook so defineX is safe to call at
module-eval time (before Pinia is ready). Async store imports inside
each mount-fn keep the registration module itself dependency-free.
Inline imperatives (imperatives.ts, NEW):
- toast.show / toast.remove / toast.removeAll
- notify({ kind, message, detail, life }) — thin convenience wrapper
- Fire-and-forget; no DisposableHandle, no defineX wrapper
- 100% imperative across 166 ecosystem hits / 16 repos (R1+R2+R3)
Net-new public arg types (extensionTypes.ts):
- CommandDefinition (alias of v1 ComfyCommand)
- HotkeyExtension (keys + commandId + optional targetElementId)
- AboutBadgeExtension (label/url/icon/severity)
- SettingDefinition<TValue> (alias of SettingParams<TValue>)
- ToolbarButtonExtension (id/icon/label/tooltip/class/onClick)
Barrel reshape (shell.ts + index.ts):
- Re-export the 5 new arg types from shell.ts
- DROP ExtensionManager + CommandManager from public re-export per
W6.P5.B reconciliation — v2 uses per-surface disposables, not
umbrella handles. Internal callers still import from
@/types/extensionTypes directly.
- Export 7 new defineX + DisposableHandle + toast/notify/NotifyOptions
from index.ts
v1 deprecation (comfy.ts):
- ComfyExtension.{commands, keybindings, settings, bottomPanelTabs,
aboutPageBadges, actionBarButtons} gain @deprecated JSDoc pointing at
the v2 defineX equivalent. Slots remain functional for back-compat
during the deprecation window (v1 extensionService still loads them).
- menuCommands + topbarBadges marked @deprecated with scope-note that
they're not part of W6.P5 (follow-on ADRs).
Runtime wiring (extension-api-service.ts):
- startExtensionSystem() dynamic-imports registrations and calls
_flushShellRegistrations() so pending defineX calls land into stores
exactly once at bootstrap.
Verification:
- tsc --noEmit: zero new errors in modified files (pre-existing node.ts
/ widget.ts errors are uncommitted working-tree state from prior
agent, not from this work)
- oxfmt: clean
- oxlint: 0 warnings / 0 errors on touched files
- vitest/knip blocked by Node 20-vs-24 env requirement (env, not code)
ADR: decisions/D-shell-ui-entrypoints.md §Implementation plan Steps 1-5.
Steps 6 (codemod) lands in PKG; Step 7 (.d.ts regen) cascades via
restack-after-foundation.sh; Steps 8-13 land in EXT/TF/docs/dashboard.
Per AGENTS.md §Decision propagation: subject contains
'D-shell-ui-entrypoints' so check-decision-propagation.sh picks it up.
Per D-coord-space ACCEPTED 2026-05-18 (option iii, Christian PICK) + new Axiom A13 (Single Coordinate Space — Canvas) in workspace AXIOMS.md.
Expands JSDoc on NodeHandle.{getPosition,setPosition,getSize,setSize} to: (1) explicitly state 'canvas units', (2) name client/CSS spaces in prose so the distinction is visible at the API boundary, (3) document the escape-hatch (window.app.canvas.ds.{scale,offset} + window.app.canvas.canvas.getBoundingClientRect() + window.devicePixelRatio) for legitimate screen-space cases, (4) point AI agents and human authors at A13 + ADR D-coord-space, (5) carry @stability stable. Also adds a SPATIAL STATE section header comment block stating the single-space policy once at the top.
No runtime surface change — getPosition/getSize/setPosition/setSize already returned/accepted canvas units post W6.P8.C (df921f3512). The PICK ships as documentation + axiom because the runtime contract was already in the right shape. R1+R2+R3 evidence (8,424 EXT hits / 103 repos / ★41,621; bug-class 2,617 hits / 71 repos / ★40,040) backs the choice.
Phase A gates: format clean / lint 0 errors 3 pre-existing warnings / knip 6 pre-existing tag hints 0 failures.
Per D-widget-converge ACCEPTED 2026-05-18 (W6.P3.B PICK: option iii) and
new Axiom A12 (Mount-Lifecycle as the Sole DOM Seam):
- Add WidgetCleanup, WidgetMountContext, WidgetMountFn to widget.ts. The
mount context provides widget/node handles plus onUnmount /
onBeforeRemount / onAfterRemount lifecycle hooks. Cleanup fires on
destruction only; host remount uses the remount hooks.
- Replace WidgetExtensionOptions.created({render, destroy}) with optional
mount: WidgetMountFn. Authors capture host element via closure inside
mount() — there is no widget.element accessor on the handle.
- Delete DOMWidgetOptions interface from node.ts.
- Delete NodeHandle.addDOMWidget() method (replaced by defineWidget + the
same addWidget call as native widgets).
- Update WidgetHandle.setHeight() JSDoc — applies to widgets registered
with a mount() body, not "DOM widgets" as a category.
- Drop DOMWidgetOptions from public barrel; add WidgetCleanup,
WidgetMountContext, WidgetMountFn to the barrel.
Phase A gates (lint + format:check + knip) pass. typecheck/test on v2
stack continue to fail per AGENTS.md Rule #8 (parallel-paths stack).
Migration path: v1 patterns (addDOMWidget, widget.element, widget.inputEl,
addWidget('unregistered-type', …)) keep working through the v1 surface
during the D6 parallel-paths window and break deliberately at D6 Phase D
sunset. See decisions/D-widget-converge.md §Migration-guide rows.
Implements the W6.P8.C foundation slice of D-immutability-enforcement
(ACCEPTED 2026-05-14, Hybrid C):
* Point/Size are now readonly tuples — getPosition()[0] = X and
getSize()[0] = X raise TS-ERR at compile time.
* getWidgets() now returns ReadonlyArray<Readonly<WidgetHandle>>
— node.getWidgets().push(...) and []= raise TS-ERR.
* inputs()/outputs() are renamed to getInputs()/getOutputs() returning
ReadonlyArray<Readonly<SlotInfo>>. Old names kept as @deprecated
aliases for one minor release; SlotInfo fields were already readonly.
* New WidgetHandle.options: Readonly<WidgetOptions> accessor — bulk read
of the options bag; widget.options.min = 0 and widget.options = {...}
raise TS-ERR. Mutate via setOption(key, value).
* New WidgetHandle.serializeValue accessor-only — direct assignment
raises TS-ERR; the v2 path is on('beforeSerialize') per D5.
* widget.value (setValue/getValue pair) is unchanged — already ZERO row
in R3 per D14.
Zero runtime cost — no Object.freeze, no Proxy. The 'as any' /
'@ts-ignore' / JS-not-TS escape gap is the explicit trade per ADR; the
follow-up W6.P8.FREEZE closes that gap when prioritized.
Refs: decisions/D-immutability-enforcement.md (ACCEPTED 2026-05-14)
research/architecture/D-immutability-enforcement-blast-radius.md
W6.P8.B PICK (handoff-6, captured in tasks/W6-P8-B.lock)
Phase A — gates: lint OK / format:check OK / knip OK.
typecheck/test failures on the v2 stack are EXPECTED per AGENTS.md
Rule #8 until rebased onto Alex's ECS branch (PR #11939).
Per decisions/D20-id-type-convergence.md (closes design-review rows #3 and #8):
- NodeHandle.entityId → NodeHandle.id (string), add equals(other)
- WidgetHandle.entityId → WidgetHandle.id (string), add equals(other)
- SlotInfo.entityId → SlotInfo.id (string), add equals(other)
- SlotInfo.nodeEntityId → SlotInfo.nodeId (string)
- Drop NodeEntityId/WidgetEntityId/SlotEntityId re-exports from src/extension-api/index.ts
- Mark the three brand re-exports in node.ts/widget.ts as @internal — still
available to internal package modules but absent from the published barrel
and TypeDoc output
- knip.config.ts: add -internal tag so the @internal demoted brands stop
failing the unused-export check
- Update scope-registry.test.ts to use handle.id instead of handle.entityId
- Update createNodeHandle/createWidgetHandle/inputs/outputs in
extension-api-service.ts to expose id + equals
- Drop unused PublicSlotEntityId import alias
Identity is now opaque on the public surface (90% case). Branded
NodeLocatorId / NodeExecutionId remain public for the protocol-boundary
10% case (workflow JSON, websocket frames) per D20 Tier 2.
Per AUDIT-LG.4 finding: the previous doc-comments had numeric values
mapped to wrong names (claimed 1=Never, 2=Bypass, 3=once, 4=trigger,
but the actual LiteGraph runtime enum is 0=ALWAYS, 1=ON_EVENT,
2=NEVER, 3=ON_TRIGGER, 4=BYPASS). An extension following the old
docs and writing setMode(3) for 'execute once' would actually wire
the dead ON_TRIGGER plumbing.
Also notes that slots 1 and 3 are legacy ABI-reserved positions for
the dead trigger/action subsystem flagged for removal by AUDIT-LG.5.
Cross-ref: research/architecture/audit-litegraph-pruning.md
\xc2\xa7AUDIT-LG.4 \xc2\xa7AUDIT-LG.5
Folds the foundation-file edits that were previously living in PRs #12143
and #12144 into the foundation PR (#12142) so each downstream PR's diff
contains only its own concerns.
From #12143 (pkg):
- src/extension-api/index.ts: drop startExtensionSystem from public barrel
(it is the internal boot fn; app.ts imports directly from the service)
- src/extension-api/types.ts: fix bad escape in widgetCreated JSDoc example
- src/services/extension-api-service.ts: add @internal tag to
startExtensionSystem; widen widget.on() Phase-A stub warnings; harden
addWidget name parse with explanatory TODO
- src/services/__tests__/scope-registry.test.ts: drop unused removedCb in
no-dispose-on-subgraph-promotion test
From #12144 (ext):
- src/extension-api/lifecycle.ts: add JSDoc @example to defineWidgetExtension
- src/extension-api/node.ts: hoist DOMWidgetOptions interface above NodeHandle
- src/extension-api/shell.ts: clarify shell.ts is a re-export, not a move
- src/services/extension-api-service.ts: more informative Phase-A stub
warning text on node.on() (replaces pkg's terser version per ext review)
These are pure refinements (JSDoc, dev-only warnings, structural tidying);
no runtime behavior change. After this commit, rebasing pkg/ext onto the
new foundation will drop the corresponding source-touching commits as
empty no-ops and leave each PR scoped to its own concerns.
D18 Phase 1 (decisions/D18-pure-functions-loader-registration.md):
- Add EXTENSION_BRAND symbol + isBrandedExtension type-guard in
src/extension-api/brand.ts. Phase 2 loader will use these to identify
define* outputs without grepping module exports.
- Stamp brand inside defineNode / defineWidget / defineExtension. Side-
effect registration remains for Phase 1; Phase 2 removes it.
- Scaffold src/services/registries/{node,widget,app}ExtensionRegistry.ts
with the {register,getAll,_clearForTesting} shape the loader will call.
D19 (decisions/D19-vueextension-disposition.md):
- Remove VueExtension and CustomExtension from the @comfyorg/extension-api
barrel and from src/extension-api/shell.ts. They remain exported from
src/types/extensionTypes.ts for ExtensionSlot.vue (the only internal
consumer).
knip.config.ts: ignore the new Phase-1-scaffolding modules until Phase 2
wires them in. All three Phase-A quality gates (lint, format:check, knip)
pass.
Add test:extension-api script that skips gracefully when the vitest
config doesn't exist. This allows CI to pass on stacked PRs that
precede the tf branch (which adds the actual tests).
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The compat-floor gate should only enforce when extension-api-v2 tests
exist. On stacked PRs that precede the tf branch (which adds tests),
gracefully skip instead of failing.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add scripts/check-compat-floor.py and research/touch-points data files
to foundation branch so CI compat-floor job passes on all stacked PRs.
Per PLAN.md §Compat-floor, all blast_radius ≥ 2.0 categories must have
complete test triples before v2 ships.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Remove startExtensionSystem from public exports (internal-only)
- Remove unused World interface export (make module-local)
- Make addDOMWidget command serializable (store element in side table)
- Wrap event values in proper event objects ({pos}, {size}, {mode})
- defineExtension/defineNode/defineWidget now return options object
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add comprehensive JSDoc explaining the implicit-context pattern for
onNodeMounted and onNodeRemoved hooks:
- Document how the runtime sets a global scope slot before nodeCreated()
- Explain why hooks must be called synchronously (Vue-style constraint)
- Add code examples showing correct vs incorrect async usage
- Document automatic cleanup and memory-safety benefits
Per design review decision #7.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Per design review decision #1: strip internal decision references
(D10c, D12) from JSDoc comments in the public API surface.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Design review cleanup:
- Remove all HR section header comments per design-review-12142.md Decision #1
- SlotEntityId changed from number to string brand (per D13 Slack resolution)
LayoutStore integration (D13 §4):
- NodeHandle.getPosition/setPosition now read/write via layoutStore
- NodeHandle.getSize/setSize now read/write via layoutStore
- Position/size are Yjs CRDT-backed with operation logging (undo/redo ready)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Change status from "scaffolded" to "implemented (Phase A)"
- Update file structure to match actual files
- Replace stale decision doc refs with ADR links
- Add related research document links
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add console.warn when node.on('beforeSerialize') is used (ADR-0010)
- Add console.warn if extensions registered but startExtensionSystem()
never called (ADR-0012 mitigation)
- Enhance JSDoc with migration examples for deprecated beforeSerialize
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Documents the 5 categories of widget state (identity, value,
properties, options bag, DOM-specific) and their constraints
for agents working with the extension API.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Analyzes whether the 4 serialization contexts can be reduced.
Recommends keeping all 4 - each has distinct semantics that
extensions may need to differentiate.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Analyzes the relationship between DOM widgets and base widgets.
Recommends keeping the current partial convergence - unified at
entity/interface/command level, separate at creation API level.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Analyzes canvas vs screen coordinates in the extension API.
Current approach is appropriate - node positions in canvas space,
widget heights in pixels.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Analyzes when extensions need raw entity IDs and recommends keeping
them exposed with clear documentation that they are opaque values.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Documents the decision to use pure function registration (defineNode,
defineWidget) with a centralized loader (startExtensionSystem) rather
than side-effect registration at import time.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Decision: All collection-returning methods (widgets(), inputs(), etc.)
and object-returning methods (getProperties()) return fresh copies.
Benefits:
- True immutability: mutations never affect internal state
- JavaScript-safe: works regardless of TypeScript types
- Simple mental model: "this is your copy"
Applied consistently across the extension API surface.
Addresses review discussion item #10 from design-review-12142.md (Topic 14)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Node-level `node.on('beforeSerialize')` is wrong-layered:
- Extension state should flow through widgets, not arbitrary node fields
- Widget-level `widget.on('beforeSerialize')` handles all legitimate use cases
- Node-level hooks encourage ad-hoc state storage that breaks clean separation
Decision: Deprecate now, remove in v1.0. Add migration guidance to ADR.
Addresses review discussion item #9 from design-review-12142.md (Topic 11)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Shorter function names improve ergonomics while maintaining clarity:
- defineNode() - register node-scoped extensions
- defineWidget() - register widget type extensions
Old names kept as deprecated aliases for backwards compatibility.
Will be removed in v1.0.
Updates all docs, examples, tests, and internal references.
Addresses review discussion item #4 from design-review-12142.md
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Remove redundant "widget" prefix since we're already in widget context.
Addresses review discussion item #5 from design-review-12142.md
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Changes:
- Strip internal decision refs (D3, D5, D6, D7, etc.) from JSDoc
- Remove @stability stable tags (pre-v1.0, nothing is stable yet)
- Remove apiVersion from ExtensionOptions (telemetry deferred)
- Change NodeMode from number union to string union
('always'|'never'|'bypass'|'once'|'onTrigger')
- Rename widget() to getWidget() for consistency
Addresses review discussion items #1, #2, #3, #7, #8 from design-review-12142.md
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
knip flagged the new src/extension-api/* files and exports as unused
because nothing internal consumes them — by design. They are the public
API surface published by PKG2 (#12143).
Changes:
- knip.config.ts: add src/extension-api/index.ts as entry point (per
AGENTS.md, this barrel is the explicit exception to the
no-barrel-files-in-src rule because it IS the package entry).
- knip.config.ts: ignore src/types/extensionV2.ts (deprecated stub,
removed once PKG2 ships).
- knip.config.ts: register `-publicAPI` tag.
- Tag externally-consumed exports with @publicAPI:
- lifecycle.ts: defineNodeExtension/Extension/WidgetExtension declares
+ back-compat type re-exports
- extension-api-service.ts: _setDispatchImplForTesting, NodeInstanceScope
- worldInstance.ts: World
Verifies the full lint-format CI matrix locally:
pnpm lint ✅
pnpm stylelint (unaffected, CSS-only)
pnpm format:check ✅
pnpm knip ✅
The previous comment described what the Vue intrinsics do but called them
no-op shims without explaining the contract gap. That read as if the
service was relying on dep-tracking suppression that the shim was silently
violating.
Rewrite the comment to make the Phase A scope explicit: this is a no-op
stub by design (the API surface is the contract being stabilized, not the
dep-tracking guarantee), and the real @vue/reactivity import lands together
with the reactive-World contract in #11939. Adds the TODO(#11939) marker so
the swap-in is grep-able alongside the rest of the Phase B handoff points.
No code change - comment-only. Per Architect review #5, we deliberately do
NOT add @vue/reactivity as a direct dep in Phase A.
Extract NodeExtensionOptions, ExtensionOptions, and WidgetExtensionOptions
into a new src/extension-api/types.ts so both the runtime service and the
public lifecycle barrel depend on the type contracts without forming a cycle.
Before:
- extension-api-service.ts imported the option types from
@/extension-api/lifecycle.
- lifecycle.ts re-exported onNodeMounted/onNodeRemoved from
@/services/extension-api-service.
-> service <-> lifecycle circular import (Architect review #3).
After:
- types.ts owns the three option-type interfaces (single source of truth).
- lifecycle.ts re-exports them from ./types so the public path
@/extension-api/lifecycle keeps working.
- extension-api-service.ts imports the types from @/extension-api/types.
- The barrel @/extension-api re-exports from ./types directly.
No public API change - all named exports remain available from
@comfyorg/extension-api and from @/extension-api/lifecycle.
Restratified foundation. Includes:
- Public API declaration files (src/extension-api/{events,identifiers,index,
lifecycle,node,shell,widget}.ts + README)
- Scope registry runtime (src/services/extension-api-service.ts) +
scope-registry tests
- Boot wiring (src/scripts/app.ts, src/services/extensionService.ts)
- Phase A ECS world stubs (src/world/{componentKey,entityIds,
widgets/widgetComponents,worldInstance}.ts)
- Public type re-exports (src/types/extensionV2.ts)
Test framework moved to ext-api/i-tf, npm package + docgen moved to
ext-api/i-pkg, core extension v2 conversions moved to ext-api/i-ext.
Original (pre-restratify) branch tip backed up at
refs/backup/restratify-20260511/ext-api-i-foundation
and tag backup/restratify/ext-api-i-foundation on fork.
*PR Created by the Glary-Bot Agent*
---
Adds a new interactive demo page at
`comfy.org/demos/community-workflows` for the [Explore and Use a
Community Workflow from the
Hub](https://app.arcade.software/flows/mqZh17oWDuWIyhK0xwEV/view) Arcade
walkthrough.
Built on top of the demo infrastructure merged in #11436.
## Changes
- `apps/website/src/config/demos.ts` — register the new demo
- `apps/website/src/i18n/translations.ts` — add en + zh-CN strings
(title, description, transcript)
- `apps/website/public/images/demos/community-workflows-og.png` —
1200×630 OG image so email/social previews render correctly
- `apps/website/public/images/demos/community-workflows-thumb.webp` —
1280×720 WebP thumbnail
- `apps/website/e2e/demos.spec.ts` — refactored to iterate `demos` from
config so every demo (current + future) is exercised in both en and
zh-CN, and the iframe `src` is asserted to contain the correct Arcade ID
Adding a new demo only requires editing `demos.ts` + `translations.ts`
going forward; the e2e refactor is a one-time generalization that gives
future demos coverage automatically.
## Verification
- `pnpm typecheck:website`: 0 errors, 0 warnings, 0 hints
- Pre-commit hook ran `pnpm typecheck`, `oxfmt`, `oxlint`, `eslint` —
all clean on staged files
- `npx astro build`: 53 pages built; `/demos/community-workflows/` and
`/zh-CN/demos/community-workflows/` generated and present in
`sitemap-0.xml`
- Page rendered in Playwright preview: hero (title, GETTING STARTED,
BEGINNER, ~2 min), Arcade embed loads, transcript section present,
"What's Next" links to `image-to-video`
- zh-CN page shows localized title (探索并使用社区工作流), description, badges,
and "What's Next" heading
- OG meta tag references the new 1200×630 PNG
## Screenshots


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11942-feat-website-add-community-workflows-demo-page-3576d73d36508139b647c774b1d39323)
by [Unito](https://www.unito.io)
---------
Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
## Automated Ingest API Type Update
This PR updates the Ingest API TypeScript types and Zod schemas from the
latest cloud OpenAPI specification.
- Cloud commit: 0a03f3a
- 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: MillerMedia <7741082+MillerMedia@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
*PR Created by the Glary-Bot Agent*
---
Converts the `CategoryNav` in the careers `RolesSection` from a
click-to-filter button into a scroll-spy section locator, matching the
pattern already used by `ContentSection.vue` (customer story details,
TOS, privacy policy).
## Changes
- **`apps/website/src/components/careers/RolesSection.vue`**
- Replaced category-based filtering with anchor navigation: clicking a
department in the sidebar smooth-scrolls (via existing Lenis/GSAP
`scrollTo` helper) to that department's section with a `-144px` header
offset.
- Removed the `ALL` button — every department is always rendered as its
own scroll target with `id="careers-dept-{key}"`.
- Added `useIntersectionObserver` (rootMargin `-20% 0px -60% 0px`) that
updates the active nav item as the user scrolls. An `isScrolling` guard
prevents the observer from fighting click-jumps mid-animation.
- Added a viewport-entry fade/slide-up animation on each department
section, gated by `motion-safe:` so users with `prefers-reduced-motion`
see content immediately. The reveal state is sticky (one-way) so
sections don't disappear once revealed.
- Active state is driven by raw department keys; both the nav model and
the observer's id-to-key mapping use a single consistent identifier.
- **`apps/website/e2e/careers.spec.ts`**
- Replaced the obsolete "ENGINEERING filter narrows the list" test with
one that validates locator behavior: clicking the department button
scrolls the section into the viewport, sets `aria-pressed="true"`, and
keeps the full role list rendered.
## Verification
- `pnpm --filter @comfyorg/website typecheck` — clean.
- `pnpm exec oxfmt` / `pnpm exec eslint` / `pnpm exec oxlint` — clean.
- Pre-commit lint-staged hooks (stylelint, oxfmt, oxlint, eslint,
typecheck) — passing.
- Manual smoke test via Playwright on `astro dev`: careers page renders
all departments stacked vertically, active department in the sidebar
highlights based on viewport position (DESIGN active on initial scroll),
nav items reflect each department instead of including an `ALL` button.
## Screenshots

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12110-feat-convert-careers-CategoryNav-to-scroll-spy-locator-35b6d73d3650818a9226e5dcb1244756)
by [Unito](https://www.unito.io)
---------
Co-authored-by: Glary <glary@bot.local>
## Summary
`.m4v` and `.mkv` files render as a generic file icon in the assets
sidebar instead of a video preview because `VIDEO_EXTENSIONS` doesn't
list them, even though both formats are widely produced by ComfyUI
custom nodes and are browser-playable when written with common codecs.
## Changes
- **What**: Add `m4v` and `mkv` to `VIDEO_EXTENSIONS` in
`packages/shared-frontend-utils/src/formatUtil.ts` and extend the
existing test cases. Aligns with ComfyUI core's canonical video
extension list
(`tests-unit/folder_paths_test/filter_by_content_types_test.py:13`),
which includes both. The frontend's format registry at
`src/platform/workflow/core/types/formats.ts` also lists `.m4v` with
mime `video/x-m4v` — `formatUtil.ts` was the inconsistent surface.
- **Breaking**: None.
- **Dependencies**: None.
## Review Focus
`m4v` is Apple's MP4 container variant; `mkv` is the Matroska container.
ComfyUI custom nodes most commonly produce both with H.264/VP9 codecs,
which play natively in modern browsers via `<video>`. Adding the
extensions routes those files through the existing `MediaVideoTop`
component without any new rendering logic.
If a user's `.mkv` happens to use an exotic codec the browser can't play
(e.g. H.265/HEVC in Chrome), they get the same controllable failure mode
as today's `.avi` entries — a `<video>` element with the browser's
native unsupported-source UI. That is no worse than the current "show a
generic file icon" behavior, and strictly better in the common case.
## Screenshots (if applicable)
> **Note**: Screenshots taken from the OSS *input* assets sidebar with
[#12086](https://github.com/Comfy-Org/ComfyUI_frontend/pull/12086) also
applied locally. That PR fixes a separate regression where OSS input
filenames carry a `[input]` annotation suffix that breaks all
extension-based media detection — without it, `m4v`/`mkv` files (and
every other file in that sidebar) still render as the generic icon. This
PR alone is sufficient for cloud assets and OSS output history; the
input-sidebar previews require both PRs.
Before:
<img width="1197" height="714" alt="2026-05-09-031123_hyprshot"
src="https://github.com/user-attachments/assets/5c6ebc2d-aac2-411f-a2e4-51a111033184"
/>
After:
<img width="1042" height="723" alt="2026-05-09-031005_hyprshot"
src="https://github.com/user-attachments/assets/f0acc2cf-8571-4fd0-b0cd-2b8b87ff9b74"
/>
*PR Created by the Glary-Bot Agent*
---
## Summary
Fixes the bug where the last badge in `ContentSection`'s sticky sidebar
nav stays unhighlighted when the user scrolls to the very bottom of the
page on tall viewports (reported on a 14" MacBook M4 Pro at 3024×1964 /
2016×1310 logical, both Chrome and Safari).
## Root cause
The scroll-spy uses an IntersectionObserver with `rootMargin: '-20% 0px
-60% 0px'`, which makes only a 20%–40% horizontal band from the viewport
top "active". When multiple intersecting entries are reported, the
callback picks the one whose `boundingClientRect.top` is smallest
(highest up on screen).
On tall viewports, when the page is scrolled to the absolute bottom, the
last *and* the second-to-last sections frequently both sit inside that
20%–40% band at the same time. The "smallest top" tiebreak then selects
the second-to-last section, leaving the last badge inactive even though
the user has reached the end of the page.
## Fix
`apps/website/src/components/common/ContentSection.vue`:
1. Add `isAtBottom()` — true when the viewport bottom has reached the
document bottom (within 4px to absorb sub-pixel rounding).
2. The IntersectionObserver callback bails out when `isAtBottom()` so it
cannot overwrite the choice below.
3. A passive `scroll` listener (and a one-shot `onMounted` call) sets
`activeSection` to the last section whenever the page is at the bottom —
including when the component mounts already at the bottom (e.g. hash
navigation to a trailing anchor, restored scroll position, or a page
shorter than the viewport).
4. Both the scroll handler and the IO callback honor the existing
`isScrolling` flag, so click-driven smooth scroll-to-section behavior is
unchanged.
## Verification
Reproduced the bug at viewport 2016×1310 (14" M4 Pro "More Space" mode)
on `/privacy-policy`:
- Before fix: at absolute bottom, IntersectionObserver picks
`australian-privacy` (second-to-last) — bug confirmed via DOM inspection
that showed multiple sections intersecting the active band, with the
second-to-last winning the "smallest top" tiebreak.
- After fix:
- Scrolled to bottom → last badge `CONTACT` is active.
- Scrolled to top → first badge `INTRO` is active.
- Scrolled mid-page → correct mid-section is active.
- Click on a badge → smooth scrolls and that badge becomes active.
- Initial render at bottom (loaded `/privacy-policy#contact`, browser
scrolls to the bottom on mount) → `CONTACT` active immediately.
`pnpm typecheck` and `pnpm typecheck:website` pass; `pnpm lint` reports
0 errors; existing website unit tests pass.
Note: The website app currently has no Vue component test setup
(`vitest.config.ts` is configured for `node` env, no DOM). Adding
component tests for this scroll-spy interaction would require setting up
`happy-dom` and `@testing-library/vue` for the website app, which is out
of scope for this bug fix.
Fixes FE-604
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12057-FE-604-fix-website-activate-last-section-badge-when-scrolled-to-bottom-3596d73d365081faa243f4dd8e6ee54a)
by [Unito](https://www.unito.io)
---------
Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
## Summary
When an image fails to load in the image preview, the context buttons
are still visible - clicking these does not work (Mask editor opens and
closes, download does nothing) - this hides the buttons if load fails.
## Changes
- **What**:
- hide buttons if load failed
- tests
## Review Focus
<!-- Critical design decisions or edge cases that need attention -->
<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->
## Screenshots (if applicable)
Current:
<img width="622" height="857" alt="image"
src="https://github.com/user-attachments/assets/26e391a0-5538-4c6c-ac8a-b6f2b6acabae"
/>
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12136-fix-hide-image-buttons-if-load-failed-35d6d73d365081579c71f1849b9ab1bd)
by [Unito](https://www.unito.io)
## Summary
After deleting an asset, the Load Image node kept displaying the deleted
thumbnail — both in the node body and in the picker dropdown (All /
Imported / Generated tabs), even after a workflow reload.
- Fixes FE-230
- Source: Slack
https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1776715727656809
## Root Cause
Three distinct paths kept the deleted asset visible:
1. **Node-body preview cache** — `useMediaAssetActions.deleteAssets`
never cleared `node.imgs` / `node.videoContainer` / the
`nodeOutputStore` Vue ref, so the canvas renderer kept its cached frame.
2. **Live-delete dropdown gap** — the picker reads from
`outputMediaAssets.media` (the asset list) and from
`missingMediaStore.missingMediaCandidates` (verified-missing names). On
live delete, neither was updated for the deleted asset, so the dropdown
filter had nothing to drop.
3. **Synthetic "selected" placeholder** —
`useWidgetSelectItems.missingValueItem` rebuilt any orphaned
`modelValue` as a fake item with a `/api/view?filename=...` preview URL.
Browsers had cached that URL pre-delete, so the deleted thumbnail still
rendered with a blue checkmark even after the filter dropped the real
asset entry.
A subtler issue compounded #2/#3: candidate names stored in
`missingMediaStore` are raw widget values (e.g. `sub/foo.png [output]`),
but the dropdown computed comparison keys differently per source (asset
list uses bare `asset.name`, widget option list uses bare filename).
Names with a subfolder prefix slipped through the filter.
## Fix
- **`clearNodePreviewCacheForFilenames`** (existing helper, refactored):
exports `findNodesReferencingFilenames` +
`extractFilenameFromWidgetValue`. Uses
`nodeOutputStore.removeNodeOutputs` so the **reactive** Pinia ref
updates, not just the legacy `app.nodeOutputs` mirror. Also clears
`node.videoContainer` for Load Video.
- **`markDeletedAssetsAsMissingMedia`** (new): on successful deletion,
surfaces the affected widgets through `missingMediaStore` immediately so
the dropdown filter has something to drop without waiting for
verification.
- **`useMissingMediaPreviewSync`** (new): watches `missingMediaStore`
and clears `node.imgs` / `node.videoContainer` / Vue preview source for
nodes referencing confirmed-missing media on workflow load — covers the
post-reload case.
- **`useWidgetSelectItems`**: normalizes both sides of the missing-media
filter via `extractFilenameFromWidgetValue` (strips
`[input|output|temp]` annotation + subfolder prefix), and suppresses
`missingValueItem` when the value is in the missing-media store so the
cached-thumbnail "selected" placeholder doesn't appear.
## Red-Green Verification
| Commit | CI Status | Run |
|--------|-----------|-----|
| `test: FE-230 add failing test for Load Image preview cache clearing`
| 🔴 Failure — test caught the bug |
https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/24700188700 |
| `fix: FE-230 clear Load Image preview cache when asset is deleted` |
🟢 Success |
https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/24700265884 |
## Test Plan
- [x] Unit coverage: 78 tests across 5 files (preview-cache helper,
mark-deleted-as-missing, missing-media-preview-sync, widget-select-items
missing-media filter incl. subfolder-prefix case, useMediaAssetActions
integration)
- [x] Live delete: Load Image node preview clears, dropdown drops the
asset across All / Imported / Generated, no synthetic "selected"
placeholder
- [x] Post-reload: missing-media verification →
`useMissingMediaPreviewSync` clears the preview, dropdown drops the
asset
- [x] Linear FE-230 auto-links via the Source line
## Scope note
In-session and session-restore are both covered. If the backend/CDN
continues serving the deleted `filename`/`asset_hash` after deletion, a
cross-session reopen may still render stale bytes from cache — that's a
backend/CDN concern tracked separately.
## demo
### before
https://github.com/user-attachments/assets/e4d3a40e-0d46-43ad-985c-22ce7e0d3faf
### after
https://github.com/user-attachments/assets/fcac9387-4c07-4be2-bcdd-d1a6192fe962
## Summary
Remove the module-level `reportedInactiveCalls: Set<string>` and the
early-return dedup check from `reportInactiveTrackerCall()` in
`src/scripts/changeTracker.ts`. Every invocation now emits
`console.warn` and (on Desktop) `Sentry.captureMessage` unconditionally.
## Why
The dedup was added in #11328 but is unnecessary:
- Every first-party call site already goes through the
`activeWorkflow?.changeTracker` guard, so flooding from in-repo code is
unlikely.
- Repeated identical alerts may actually provide more diagnostic signal
than the first-only approach suppresses.
## Changes
- Drop `reportedInactiveCalls` Set
- Drop the per-`(method, workflowPath)` early-return
- Trim the JSDoc accordingly
No behavior change for callers (`deactivate`, `captureCanvasState`);
only the reporting frequency increases.
## Verification
- `pnpm test:unit -- src/scripts/changeTracker.test.ts` — 16/16 passing
- `pnpm typecheck` — clean
- ESLint / oxfmt — clean
- Fixes#11372
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11833-refactor-remove-dedup-complexity-from-reportInactiveTrackerCall-3546d73d365081fabf57cbf1fa17051f)
by [Unito](https://www.unito.io)
## Summary
Replace `nodeId: string` with canonical `NodeId` type in right-side
panel parameters tab components, eliminating redundant `String()`
conversions at call sites.
## Changes
- `TabNodes.vue`: `isSectionCollapsed` and `setSectionCollapsed` now
accept `NodeId` instead of `string`; callers updated to pass `node.id`
directly (removing `String()` wrapping)
- `TabNormalInputs.vue`: same pattern
## Notes
The other 6 files listed in the issue use `nodeId` parameters that carry
execution IDs (`NodeExecutionId = string`), not graph node IDs (`NodeId
= number | string`). Changing those to `NodeId` would be semantically
incorrect. The two files changed here are the clear-cut cases where
`node.id` (a `NodeId`) was being unnecessarily stringified before being
passed.
## Testing
### Automated
- `pnpm typecheck` — passes
- `pnpm lint` — passes (0 warnings, 0 errors)
- `pnpm format:check` — passes
### E2E Verification Steps
1. Open ComfyUI frontend
2. Load a workflow with multiple nodes
3. Open the right side panel (Parameters tab)
4. Verify node sections collapse/expand correctly per node
5. Verify "Collapse All" / "Expand All" toggle works correctly
6. Repeat with both TabNodes and TabNormalInputs views
Fixes#11843
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12014-chore-11843-replace-bare-string-NodeId-typings-in-parameters-tab-components-3586d73d365081ed84caf560277f0553)
by [Unito](https://www.unito.io)
## Summary
This PR fixes missing-media false positives for annotated media widget
values such as:
```txt
photo.png [output]
clip.mp4 [input]
147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png [output]
clip.mp4[input] // Cloud compact form
```
The change is intentionally scoped to the missing-media detection
pipeline for:
- `LoadImage`
- `LoadImageMask`
- `LoadVideo`
- `LoadAudio`
It preserves the raw widget value on `MissingMediaCandidate.name` for UI
display, grouping, replacement, and user-facing missing-media rows.
Normalized values are used only as comparison keys during verification.
## Diff Size
`main...HEAD` line diff is currently:
- Production/runtime code: `+478 / -37` (`515` changed lines)
- Unit test code: `+960 / -47` (`1,007` changed lines)
- Total: `+1,438 / -84` (`1,522` changed lines)
The PR looks large mostly because it locks both Cloud and OSS/Core
runtime paths with unit coverage; the production/runtime change is about
one third of the total diff.
## What Changed
- Added missing-media-scoped annotation helpers for detection-only path
normalization.
- Core/OSS recognizes spaced suffixes like `file.png [output]`.
- Cloud also recognizes compact suffixes like `file.png[output]`.
- User-selectable trailing `input` and `output` annotations are
normalized for matching.
- Unknown annotations and middle-of-filename annotations are left
unchanged.
- Added shared file-path helpers in `formatUtil`:
- `joinFilePath(subfolder, filename)`
- `getFilePathSeparatorVariants(filepath)`
- Updated media verification to compare candidates against both raw and
normalized match keys.
- Kept input candidates and generated output candidates in separate
identifier sets so an input asset cannot accidentally satisfy an output
reference with the same name.
- Moved missing-media source loading into `missingMediaAssetResolver` so
`missingMediaScan` remains focused on scan/verification orchestration.
- Updated Cloud generated-media verification to use the Cloud assets API
instead of job history:
- Cloud input candidates use input/public assets.
- Cloud output candidates use `output` tagged assets.
- Kept OSS/Core generated-media verification history-based, matching the
current generated-picker/widget availability model.
## Runtime Verification Paths
### Cloud
Cloud stores generated outputs as asset records. For an annotated output
value, this PR verifies against the `output` asset tag rather than job
history.
```txt
Widget value
"147257...d6e.png [output]"
|
v
Detection keys
"147257...d6e.png [output]"
"147257...d6e.png"
|
v
Cloud asset sources
input candidates -> /api/assets?include_tags=input&include_public=true
output candidates -> /api/assets?include_tags=output&include_public=true
|
v
Match against
asset.name
asset.asset_hash
subfolder/asset.name
subfolder/asset.asset_hash
slash and backslash separator variants
```
Example:
```ts
candidate.name = 'abc123.png [output]'
asset.name = 'ComfyUI_00001_.png'
asset.asset_hash = 'abc123.png'
asset.tags = ['output']
// Result: not missing
```
### OSS / Core
Core widget options for the normal loader nodes are input-folder based.
Annotated output values are resolved by Core through
`folder_paths.get_annotated_filepath()`, but the current generated
picker path is history-backed. This PR keeps OSS generated verification
aligned with that widget availability model instead of treating the full
output folder as the source of truth.
```txt
Widget value
"subfolder/photo.png [output]"
|
v
Detection keys
"subfolder/photo.png [output]"
"subfolder/photo.png"
|
v
OSS generated source
fetchHistoryPage(...)
|
v
History preview_output
filename: "photo.png"
subfolder: "subfolder"
|
v
Generated match keys
"subfolder/photo.png"
"subfolder\\photo.png"
```
This means OSS/Core verification is about whether the generated media is
currently available through the same generated/history-backed path the
widget uses, not a full disk-level executability check across the entire
output directory.
## Why Not Consolidate All Annotated Path Parsers
There are existing annotated-path parsers in image widget, Load3D, and
path creation code. This PR does not replace them.
The helper added here is detection-only: it strips annotations to build
comparison keys for missing-media verification. Parser consolidation
across widget implementations is intentionally left out of scope to keep
this fix narrow.
## Known Follow-Ups / Out Of Scope
- FE-620 tracks the separate video drag-and-drop upload race between
upload completion and missing-media detection.
- Published/shared workflow assets are still not fully represented by
`/api/assets?include_public=true`; that remains a backend/API contract
issue.
- A future backend/API contract that answers “is this workflow media
executable?” would be preferable to stitching together runtime-specific
FE sources.
- OSS/Core full output-folder scanning via `/internal/files/output` was
considered, but that endpoint is internal, shallow (`os.scandir`), and
not the same source currently used by the generated picker flow.
## Validation
- `pnpm test:unit -- missingMediaAssetResolver missingMediaScan
mediaPathDetectionUtil formatUtil`
- touched files `oxfmt`
- touched files `oxlint --fix`
- touched files `eslint --cache --fix --no-warn-ignored`
- `pnpm typecheck`
- pre-commit `pnpm knip --cache`
- pre-push `pnpm knip --cache`
`knip` passes with the existing tag hint:
```txt
Unused tag in src/scripts/metadata/flac.ts: getFromFlacBuffer → @knipIgnoreUnusedButUsedByCustomNodes
```
## Screenshots
Before
https://github.com/user-attachments/assets/50eab565-3160-4a57-a758-87ec2c09071e
After
https://github.com/user-attachments/assets/08adcbbd-c3fc-43f9-b86c-327e4eb5abd8
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12069-fix-handle-annotated-output-media-paths-in-missing-media-scan-3596d73d365081f4afa3d4dd45cad3da)
by [Unito](https://www.unito.io)
*PR Created by the Glary-Bot Agent*
---
## Summary
Sort the canvas right-click "Add Node" context menu by display name
(case-insensitive, natural numeric). Previously, both category submenus
and leaf nodes appeared in node-registration order, making the menu hard
to scan for users browsing for nodes.
This change is scoped specifically to the **smaller right-click
contextual menu**. It does NOT affect the double-click search menu or
the left-side Nodes panel.
## Changes
- `src/lib/litegraph/src/LGraphCanvas.ts` — In `onMenuAdd` →
`inner_onMenuAdded`, sort the deduplicated category submenu entries and
the leaf-node entries by `content` using `localeCompare` with `{
numeric: true, sensitivity: 'base' }`. Categories still appear before
leaf nodes within a level (preserves existing UX).
- `src/lib/litegraph/src/LGraphCanvas.onMenuAdd.test.ts` — New unit
tests that mock `LiteGraph.ContextMenu` and assert: case-insensitive
sort, natural numeric ordering (`Cat1`, `Cat2`, `Cat10`), leaf-node
sorting inside a category, and category-before-leaf placement.
## Verification
- `pnpm vitest run src/lib/litegraph/src/LGraphCanvas.onMenuAdd.test.ts`
— 4/4 pass
- `pnpm typecheck` — clean (ran via pre-commit hook on initial commit)
- `oxfmt` / `oxlint` / `eslint` — clean
- Oracle review against `main` returned 0 critical / 1 warning (test
coverage) / 1 suggestion (numeric sort) — both addressed in this PR.
## Notes
- The sort is applied at the menu-build site rather than inside
`LiteGraphGlobal.getNodeTypesCategories`/`getNodeTypesInCategory` to
keep the change scoped to the menu UX and avoid changing the iteration
order seen by extensions that consume those public methods.
- Per user request, this is opening as a draft PR for self-review +
CodeRabbit feedback in a single follow-up pass; manual browser
verification (right-click screenshots) was deferred to that pass.
- Slack thread context: user reported the contextual menu is "a mess"
for discovering native nodes; alphabetical sorting addresses the
discoverability problem without touching the search-oriented menus.
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12039-feat-sort-right-click-context-menu-categories-alphabetically-3596d73d36508107a87ffec1c353994e)
by [Unito](https://www.unito.io)
---------
Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Alexis Rolland <alexis@comfy.org>
## Summary
Imported assets render as a generic check-check icon instead of a
thumbnail because the OSS `/internal/files/{type}` endpoint returns
annotated filenames (`photo.png [input]`) that the assets-sidebar mapper
passes through verbatim, which breaks extension-based media-type
detection.
## Changes
- **What**: Strip ComfyUI's trailing directory-type annotation (`
[input]`, ` [output]`, `[temp]`) in `mapInputFileToAssetItem` so `name`,
`id`, and the generated `/view?filename=…` URL all use the canonical
on-disk filename. Adds a focused unit test.
- **Breaking**: None.
- **Dependencies**: None.
## Review Focus
### Root cause
ComfyUI core PR
[comfyanonymous/ComfyUI#13078](https://github.com/comfyanonymous/ComfyUI/pull/13078)
(April 2026) changed `/internal/files/{type}` to append the directory
type to each entry:
```python
# api_server/routes/internal/internal_routes.py
return web.json_response(
[f"{entry.name} [{directory_type}]" for entry in sorted_files], status=200
)
```
The annotation is the wire format `LoadImage`-style widgets expect, so
the backend change is correct. The assets-sidebar mapper treated the
response strings as raw filenames. After
[#8914](https://github.com/Comfy-Org/ComfyUI_frontend/pull/8914) changed
`getMediaTypeFromFilename` to default unknown extensions to `'other'`,
every input asset now routes to `MediaOtherTop` and renders as
`icon-[lucide--check-check]`:
```
getMediaTypeFromFilename("photo.png [input]").split('.').pop() === "png [input]" → 'other'
```
The strip happens at data ingestion so every consumer of
`AssetItem.name` (sidebar grid, list, filter, gallery, drag-drop,
delete) gets the canonical filename automatically. OSS-only — Cloud
paths get clean names from the cloud API and are unaffected.
Reproduces locally on stock OSS ComfyUI on `main` of both repos; no
public issue tracker entry.
## Screenshots (if applicable)
Before:
<img width="1091" height="718" alt="image"
src="https://github.com/user-attachments/assets/ff1f070d-da39-4e5a-bc6d-99b7214f7da8"
/>
After:
<img width="1089" height="716" alt="image"
src="https://github.com/user-attachments/assets/7123d9bf-f7dd-4430-b6f7-f6702b70baaa"
/>
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12086-fix-assets-strip-directory-annotation-from-input-filenames-35a6d73d365081e9b9eed7d8630d6f0b)
by [Unito](https://www.unito.io)
*PR Created by the Glary-Bot Agent*
---
## Summary
Disable the `UltralyticsDetectorProvider` model-to-node mapping so the
node falls back to the static combo populated from `/api/object_info`,
restoring pre-#8468 behavior on cloud.
## Why
PR #8468 opted `UltralyticsDetectorProvider` into the cloud asset-widget
path, which exposed a latent mismatch in cloud asset metadata for
nested-directory model folders. The bug has two independent halves, and
a fix that addresses only one will still leave the workflow broken at
execution time:
- **Tag lookup mismatch.** Cloud stores tags as combined values like
`ultralytics/bbox`, while the asset query asks for split tags (`models`
+ `ultralytics`) with exact-match filtering — so the dropdown returns no
results.
- **Submitted value mismatch.** Cloud stores filenames as basenames, but
the node expects subdirectory-prefixed values (e.g.
`bbox/face_yolov8m.pt`) that the static combo path normally produces.
Both halves require cloud-side fixes (asset ingestion + metadata) before
the asset-browser registration can be safely re-enabled. Until then,
removing the registration restores the working static-combo behavior so
users are unblocked.
## Changes
- `src/platform/assets/mappings/modelNodeMappings.ts`: comment out the
`['ultralytics', 'UltralyticsDetectorProvider', 'model_name']` entry
with a note pointing at BE-689 and the re-enablement criteria.
- `src/stores/modelToNodeStore.test.ts`: drop the now-stale ultralytics
expectations from `EXPECTED_DEFAULT_TYPES`, `MOCK_NODE_NAMES`, and the
hierarchical-fallback `it.each` cases.
## Verification
Local quality gates:
- `pnpm typecheck` — clean
- `pnpm lint` — clean (3 pre-existing warnings, 0 errors)
- `pnpm format:check` — clean
- `pnpm knip` — clean (1 pre-existing warning unrelated to this change)
- `pnpm test:unit -- src/stores/modelToNodeStore.test.ts` — 51/51
passing
Manual runtime verification (dev server + Playwright against the live
module):
- `MODEL_NODE_MAPPINGS` no longer contains any entry where `[0] ===
'ultralytics'` or `[1] === 'UltralyticsDetectorProvider'` (84 entries
total, 0 ultralytics).
- `useModelToNodeStore().getNodeProvider('ultralytics')` returns `null`
after `registerDefaults()`, so the asset-widget path is no longer
triggered for this node.
- `getNodeProvider('ultralytics/bbox')` also returns `null`, confirming
hierarchical fallback no longer resolves to the disabled mapping.
- `getNodeProvider('checkpoints')` still resolves to
`CheckpointLoaderSimple`, confirming unrelated mappings are intact.
End-to-end cloud verification (actually exercising the asset-browser
path against cloud-seeded ultralytics metadata) is not possible in the
local sandbox, since the regression depends on the cloud's asset
ingestion data shape. The change is a single mapping-table removal that
reverts to the well-exercised static-combo path covered by the updated
unit tests.
Long-term cloud-side fix is tracked in BE-689.
- Fixes BE-689
## Screenshots

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12075-fix-disable-ultralytics-asset-browser-registration-35a6d73d36508179b394f0915e69742e)
by [Unito](https://www.unito.io)
Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
## Summary
The help icon (lucide circle-help) next to the credits balance in the
cloud user popover was a bare `<i>` with `v-tooltip` and `cursor-help`.
PrimeVue tooltip on a bare `<i>` did not fire reliably and the icon had
no focus/keyboard semantics, so users saw "no hover action and not
clickable".
Wrap the icon in `<Button variant="muted-textonly" size="icon-sm">`,
matching the existing pattern in `InfoButton.vue` and
`MissingPackGroupRow.vue`. Same change applied to
`CurrentUserPopoverLegacy.vue` and `CurrentUserPopoverWorkspace.vue`,
which shared the broken pattern.
- Fixes FE-617
-
https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1778191473621829
## Red-Green CI Verification
The branch was force-pushed back to the test-only commit so CI could run
against it, then restored to the fix commit.
| Commit | CI: Tests Unit | Outcome |
|--------|---------------|---------|
| `test:` (e7c83abd0) — adds the regression test only |
https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/25532935842 |
failed — `Unable to find an element by:
[data-testid="credits-info-button"]` |
| `fix:` (64ec4cda4) — wraps the icon in `<Button>` |
https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/25533224195 |
passed |
<img width="434" height="364" alt="Screenshot 2026-05-08 at 5 32 47 PM"
src="https://github.com/user-attachments/assets/d3088b90-813f-4a0f-ba35-0f040fc79a6a"
/>
## Test Plan
- [x] Component test asserts the icon renders as an interactive
`<button>` with the unified-credits tooltip text as `aria-label`
- [x] Red CI failed with the expected error on the test-only commit
- [x] Green CI passed on the fix commit
- [ ] Manual verification on `pnpm dev:cloud` — hover the help icon next
to the credits balance and confirm the unification tooltip appears
## Summary
The nightly `Release Draft Create` -> `publish_types / Build types` job
has been failing on every run since 1.45.1 ([failed
run](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/25531376650)).
Reproduced locally on `main` (`c8c0e5386`).
## Root cause
`pnpm build:types` ends with `FATAL ERROR: Reached heap limit Allocation
failed - JavaScript heap out of memory`. The `vite-plugin-dts` rolled-up
type generation now exceeds Node's default ~4GB heap on the GH runner.
The `TS2742`/`TS4082` warnings printed earlier are non-fatal diagnostics
from the plugin pre-pass — the api-extractor rollup itself completes
once the heap is large enough.
## Fix
Set `NODE_OPTIONS='--max-old-space-size=8192'` in `build:types`,
matching the existing pattern already used by `build` and `build:cloud`
(only one-line change).
## Verification
- `pnpm build:types` exits 0 locally with the change (built in ~40s).
- `dist/index.d.ts` (1.9MB) emitted with the public types intact:
`ComfyExtension`, `ComfyApi`, `ComfyApp`, `ComfyNodeDef`, `InputSpec`,
`DOMWidget`, etc.
- `dist/package.json` correctly produced by `scripts/prepare-types.js`.
- Reverting the change reproduces the OOM crash.
## Test plan
- [ ] Trigger `Release NPM Types` workflow manually (or wait for next
nightly) and confirm `Build types` step succeeds.
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12073-fix-restore-nightly-publish_types-build-35a6d73d3650819e95aecfbd8a66847c)
by [Unito](https://www.unito.io)
## Summary
This PR removes the `/api/assets/hash/:hash` verification path from
missing media/model detection.
I decided to remove this path for two reasons:
1. The Cloud runtime implementation and the OpenAPI/generated FE
contract do not agree on the hash format that this endpoint represents.
In current Cloud data, the dominant asset_hash shape is
<64-hex>.<extension> (for example, abc123....png), while the
OpenAPI/generated FE contract expects a blake3:<hash> style value. That
makes this path either dead code that should never be reached, or, when
it is reached, a request that always returns 400 and only adds
unnecessary noise.
2. Even if the format is reconciled, the Cloud implementation is a
global deduplication-oriented lookup, not an access-aware check for
whether the current workflow can use a resource. In theory, it can
return success for another user's personal asset, so it is the wrong
primitive for missing asset detection.
Because of that, this PR makes the existing asset list/store based
checks the primary verification path and removes the hash-specific
helpers, service method, and tests.
## Known follow-ups
These are known issues that are intentionally not solved in this PR:
1. Published assets are not exposed through
`/api/assets?...include_public=true`. This is a backend issue and can
still cause mismatch between missing-asset detection and resources that
preview/run successfully.
2. Shared workflow import has an ordering issue. The API contract issue
is being hotfixed separately under FE-603.
3. Annotated media paths can still be detected incorrectly.
I will prepare follow-up PRs for these, starting with the annotated
media path issue because that is the most critical frontend-side gap.
## Validation
- `pnpm exec vitest run
src/platform/assets/services/assetService.test.ts
src/platform/missingMedia/missingMediaScan.test.ts
src/platform/missingModel/missingModelScan.test.ts`
- `pnpm lint:unstaged`
- `pnpm typecheck`
- `pnpm knip`
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12061-fix-remove-asset-hash-verification-3596d73d365081a088f8dfc874724c1d)
by [Unito](https://www.unito.io)
*PR Created by the Glary-Bot Agent*
---
## Summary
The horizontal progress bar in `FeedbackSection.vue` lagged behind the
carousel scroll position because the bar's width was driven reactively
by `useScroll`, but the `transition-all duration-200` utility animated
each width change over 200ms. As scroll continuously emits new target
widths, the bar visibly trailed the scroll position.
Removing the transition makes the bar track scroll synchronously.
## Verification
- Reproduced the lag locally on `/customers`.
- Verified post-fix that `bar.style.width` updates in the same frame as
`scrollLeft` changes (samples at scrollLeft 0 / 944 / 1600 → width 0% /
59% / 100%, with `transitionDuration: 0s`).
- `pnpm exec eslint`, `pnpm exec oxfmt --check`, `pnpm nx typecheck
website`, and `pnpm test:unit` all pass.
## Notes
No regression test added — the customers section components have no
existing unit/E2E coverage in this repo, and standing up a new test
harness for a one-line CSS fix would be disproportionate. Worth
following up on broader carousel coverage as a separate task.
## Screenshot
After fix, scrolled to second slide — progress bar tracks scroll
position synchronously.
## Screenshots

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12059-fix-remove-transition-delay-from-FeedbackSection-progress-bar-3596d73d36508107bc80dc38ea7ab79e)
by [Unito](https://www.unito.io)
Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
*PR Created by the Glary-Bot Agent*
---
## Summary
Fix the video position glitch on `/customers` caused by
`c-projection.webp` loading.
## Root cause
In `HeroSection.vue` the hero `<img>` for `c-projection.webp` had no
`width`/`height` attributes, so the browser reserved no space for it.
When the image finished loading, the layout shifted ~192px, pushing the
`VideoPlayer` below it. `useHeroAnimation` registers a GSAP
`ScrollTrigger` parallax against the video in `onMounted` (before the
image is loaded), so the cached scroll geometry then went stale and the
video visibly glitched.
## Fix
- Add explicit `width="1568"` / `height="1763"` to the `<img>` (the
image's native size) so the browser reserves the correct aspect-ratio'd
space upfront.
- Add `h-auto` so the height attribute doesn't override the responsive
layout.
- Refresh `ScrollTrigger` on the image's `@load` (with `refresh(true)`
so measurements happen after layout has settled) as a defensive measure
for any sub-pixel adjustments.
## Test coverage
Added `apps/website/e2e/customers.spec.ts` with a regression guard that:
1. Asserts the hero `<img>` declares numeric `width`/`height`
attributes.
2. Asserts the unloaded image still reserves vertical space (>100px),
which is the exact property that prevents the video from jumping when
the image finishes loading.
Verified the test fails when the `width`/`height` attributes are removed
and passes with the fix applied.
## Verification
- `pnpm typecheck` clean.
- `pnpm test:unit` (website app) — 30/30 pass.
- `pnpm test:e2e customers.spec.ts --project=desktop` — passes.
- ESLint + oxfmt clean on changed files.
- Manual Playwright verification confirmed the video bounding rect stays
at `top=785, height=628` through the full image load lifecycle.
Reverting the fix in DevTools and re-loading the image reproduces the
original ~192px shift.
## Out of scope
`apps/website/src/components/contact/FormSection.vue` uses the same
`c-projection.webp` pattern (also without `width`/`height`). It runs
`useHeroAnimation` with `parallax: false`, so the symptom is much
smaller — leaving as a follow-up to keep this PR minimal.
- Fixes
[FE-607](https://linear.app/comfyorg/issue/FE-607/bug-video-on-customers-shifts-position-when-c-projectionwebp-finishes)
## Screenshots

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12060-fix-website-prevent-video-glitch-when-c-projection-webp-loads-on-customers-3596d73d365081ebbcb8db25aaa5c451)
by [Unito](https://www.unito.io)
---------
Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
## Summary
Unblock CI due to missing function in mock
```
⎯⎯⎯⎯ Unhandled Rejection ⎯⎯⎯⎯⎯
TypeError: this.load3d.emitModelReady is not a function
❯ src/extensions/core/load3d/Load3DConfiguration.ts:313:19
311| }
312|
313| this.load3d.emitModelReady()
| ^
314| }
315| }
This error originated in "src/extensions/core/load3d/Load3DConfiguration.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.
The latest test that might've caused the error is "prefers persisted Scene/Camera/Light Config over settings". It might mean one of the following:
- The error was thrown, while Vitest was running this test.
- If the error occurred after the test had been completed, this was the last documented test before it was thrown.
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
Test Files 773 passed (773)
Tests 10417 passed | 8 skipped (10425)
Errors 2 errors
Start at 12:06:06
Duration 446.79s (transform 32.89s, setup 113.26s, import 589.18s, tests 109.19s, environment 228.45s)
```
## Changes
- **What**:
- add `emitModelReady` fn to mock
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12056-test-add-missing-emitModelReady-to-mock-object-3596d73d36508130a087f5c7713e932f)
by [Unito](https://www.unito.io)
## Summary
Follow-up to #11568 and #11785. Keeps the E2E coverage workflow clean
when `CI: Tests E2E` is intentionally skipped and no coverage shard
artifacts are produced.
## Changes
- Detect whether downloaded E2E coverage shard artifacts contain any
`coverage.lcov` files.
- Treat missing coverage shards as an intentionally skipped coverage run
instead of running lcov, Codecov, or Pages deployment on missing files.
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11575-ci-handle-skipped-e2e-workflow-consumers-34b6d73d3650813f92e1d7d6af0bf958)
by [Unito](https://www.unito.io)
## Summary
Align the asset list pagination schema with generated ingest-types
metadata and remove the now-unneeded missing `has_more` fallback branch.
## Changes
- **What**: Reuse `zListAssetsResponse` for `total` and `has_more`, keep
the local loose `AssetItem` shape, and simplify `getAllAssetsByTag()` to
trust the required `has_more` contract.
- **Breaking**: None.
- **Dependencies**: None.
## Review Focus
This is PR 1 of 4 in the missing asset follow-up stack:
1. This PR - Asset schema / pagination cleanup
2. #11900 - Missing asset hash verification utility cleanup
3. #11901 - Browser regression coverage for public input assets
4. #11902 - TanStack Query public-input cache replacement
The key decision is intentionally narrow: pagination metadata now comes
from generated ingest-types, but asset item validation remains locally
loose to avoid changing UI/store synthetic asset shapes in this PR.
`asset_hash` nullability remains unchanged because absent-vs-null hash
semantics are still a backend/API contract follow-up.
Addresses #11894
## Screenshots (if applicable)
N/A
*PR Created by the Glary-Bot Agent*
---
Adds an entry for the new `SaveImageAdvanced` node to
`public/assets/sorted-custom-node-map.json` with the same frequency stat
(1762) as the existing `SaveImage` node, so the new Save Image node
ranks at the top of search results when typing "save" — matching the
original node's behavior.
Context: the new Save Image node ([Notion
spec](https://www.notion.so/comfy-org/Save-Image-94a77c506ce145fc9b8c477c52091a04))
replaces/deprecates the original `SaveImage`. Search ranking uses the
static node frequency map; the new node had no entry and was therefore
ranked at frequency 0. Mirroring the original's stat is the manual-boost
approach discussed in the thread.
## Changes
- `public/assets/sorted-custom-node-map.json`: add `"SaveImageAdvanced":
1762` directly after `"SaveImage": 1762` to preserve descending sort
order.
## Verification
- `pnpm typecheck`, `pnpm lint`, and `pnpm format` all pass via
lint-staged on commit.
- JSON validated and entry placement confirmed (position 4, between
`SaveImage` and `VAEDecode`).
- Review (oracle) ran clean: 0 critical / 0 warning / 0 suggestion.
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11853-feat-boost-SaveImageAdvanced-node-frequency-for-search-ranking-3546d73d36508168b058d9d750fc3c56)
by [Unito](https://www.unito.io)
Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
*PR Created by the Glary-Bot Agent*
---
## Summary
Replaces `right1.webp` with `right1.webm` in the VFX panel of
`UseCaseSection`. `BlobMedia` already auto-detects `.webm` URLs and
mounts a `<video>` element (with the `.webp` as poster), so this single
URL swap is the only change required — matching the pattern used by the
other 4 use-case panels.
## Files changed
- `apps/website/src/components/home/UseCaseSection.vue` — swap
`right1.webp` → `right1.webm`.
## Verification
- `pnpm exec nx run website:typecheck` — clean
- `pnpm exec eslint` on changed file — clean
- `pnpm exec oxfmt --check` — clean
- pre-commit lint-staged hooks — passed
## Reviewer note (Oracle finding)
VFX is the default active panel, so the homepage's initial right-rail
asset moves from ~131 KB `.webp` to ~4.1 MB `.webm`. Behaviorally
consistent with the other 4 panels (which already use `.webm`), but
worth confirming whether `right1.webm` should be re-encoded smaller on
the CDN before promoting this PR out of draft.
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12040-fix-use-webm-video-for-VFX-use-case-right-asset-3596d73d365081829976f37b733840f1)
by [Unito](https://www.unito.io)
---------
Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Amp <amp@ampcode.com>
## Summary
The dirtyClose modal had three buttons (`Cancel | No | Save`) and the
sign-out flow collapsed two distinct outcomes (deny vs. dismiss) into a
single early return — so today clicking "No" *cancels* sign-out instead
of signing out without saving, and clicking "Save" never actually saves
before logging out. This PR drops `Cancel` for `dirtyClose`, gives each
caller a context-specific deny label, and fixes the sign-out 3-state
handling.
- Fixes
[FE-419](https://linear.app/comfyorg/issue/FE-419/unsaved-changes-modal-uses-confusing-button-labels)
## Changes
- **What**:
- `ConfirmationDialogContent.vue`: hide `Cancel` for
`type='dirtyClose'`; add `denyLabel?: string` prop; autofocus `Save`
(preserves work on Enter).
- `dialogService.confirm()`: accept and forward `denyLabel`.
- `useAuthActions.logout`: handle `null` (cancel) / `false` (sign out
anyway, no save) / `true` (save each modified workflow, then logout)
distinctly. Pass `denyLabel: 'Sign out anyway'`.
- `workflowService.closeWorkflow`: pass `denyLabel: 'Close anyway'`.
- i18n: add `auth.signOut.signOutAnyway` and
`sideToolbar.workflowTab.closeAnyway`.
- **Breaking**: none. The `denyLabel` prop is optional and falls back to
`g.no`.
## Review Focus
- The "Save" branch in `useAuthActions.logout` now iterates
`workflowStore.modifiedWorkflows` and awaits
`useWorkflowService().saveWorkflow(workflow)` for each before calling
`authStore.logout()`. The close-tab path
(`workflowService.closeWorkflow`) was already correct — only the
sign-out path needed the same shape.
- `ConfirmationDialogContent` autofocus moves from `Cancel` (gone for
`dirtyClose`) to `Save`. The dialog is still dismissable via ESC /
outside-click, which routes through `dialogComponentProps.onClose →
resolve(null)` — sign-out and close-tab both treat `null` as cancel.
- Out of scope: the native browser `beforeunload` warning
(`UnloadWindowConfirmDialog.vue`) is a separate flow and never reaches
the in-app modal.
## Tests
- Unit (`useAuthActions.test.ts`, new): logout handles `null` / `false`
/ `true` / no-modified-workflows; saves *every* modified workflow before
`authStore.logout`; passes `denyLabel='Sign out anyway'`.
- Unit (`ConfirmationDialogContent.test.ts`): Cancel hidden for
`dirtyClose`; custom `denyLabel` rendered; falls back to `g.no` when
omitted.
- E2E (`workflowTabs.spec.ts`): modified-tab close shows `Close anyway`
(not `No`) and no `Cancel`; clicking `Close anyway` removes the tab; ESC
keeps the tab.
## screenshot
### AS IS
<img width="816" height="379" alt="Screenshot 2026-04-27 at 5 40 19 PM"
src="https://github.com/user-attachments/assets/a8e39403-bf72-455a-8d86-6ceb1f94ac85"
/>
<img width="923" height="396" alt="Screenshot 2026-04-27 at 5 40 38 PM"
src="https://github.com/user-attachments/assets/08031c7c-b3a6-45d7-a4dc-5dcb4e63cfa0"
/>
### TO BE
<img width="1661" height="872" alt="Screenshot 2026-04-27 at 5 43 40 PM"
src="https://github.com/user-attachments/assets/b89d160b-be66-450e-981e-32b1591f6841"
/>
<img width="1488" height="584" alt="Screenshot 2026-04-27 at 5 44 21 PM"
src="https://github.com/user-attachments/assets/b3a141a7-1f3b-4f25-85a9-49529229c28b"
/>
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11669-fix-clarify-unsaved-changes-modal-buttons-and-fix-sign-out-3-state-34f6d73d365081bf8afad8e146b3b990)
by [Unito](https://www.unito.io)
*PR Created by the Glary-Bot Agent*
---
## Summary
PR #10890 routed the legacy action bar feedback button and the Help
Center feedback item to the nightly Typeform survey, but the **default
topbar feedback button** in `WorkflowTabs.vue` still called
`buildFeedbackUrl()` and opened Zendesk. Since `Comfy.UI.TabBarLayout`
defaults to `Default` (not `Legacy`), most Cloud/Nightly users were
clicking the WorkflowTabs button and never reaching the Typeform survey
— explaining the lack of survey responses.
## Changes
- Added a shared `buildFeedbackTypeformUrl(source)` helper in
`platform/support/config.ts` that tags the survey URL with:
- `distribution`: `ccloud` / `oss-nightly` / `oss` (preserves the
build-tagging the old `buildFeedbackUrl()` sent to Zendesk so responses
stay segmented)
- `source`: `topbar` / `action-bar` / `help-center` (identifies which UI
entry point launched the survey)
Tags are passed via the URL fragment (Typeform's hidden-field
convention), so they reach the survey but are never sent to the server
in the request line.
- `WorkflowTabs.vue`: replaced `buildFeedbackUrl()` with
`buildFeedbackTypeformUrl('topbar')`.
- `cloudFeedbackTopbarButton.ts` and `HelpCenterMenuContent.vue`: use
the shared builder with their respective source labels instead of inline
URL literals.
- Removed the now-unused `buildFeedbackUrl()` and
`ZENDESK_FEEDBACK_FORM_ID` (knip-clean). `buildSupportUrl()` is
preserved — `Comfy.ContactSupport` (the Help Center "Help" item) still
routes to Zendesk as before.
- Added unit tests for the builder, the WorkflowTabs feedback button,
the legacy action bar button, and the Help Center feedback item
(covering both the Cloud/Nightly Typeform path and the OSS
`Comfy.ContactSupport` fallback).
## Verification
- `pnpm format`, `pnpm lint`, `pnpm typecheck`, `pnpm knip`: clean (one
pre-existing unrelated lint warning in `useWorkspaceBilling.test.ts`)
- `pnpm test:unit` (impacted scope): 506/506 passing, including 13 new
tests
## Review Focus
- Cloud/Nightly gating in `WorkflowTabs.vue` (`v-if="isCloud ||
isNightly"`) is unchanged and matches PR #10890's gating philosophy.
- The Help Center "Help" item and `Comfy.ContactSupport` command
intentionally still route to Zendesk — feedback ≠ support.
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11863-fix-route-default-topbar-feedback-button-to-Typeform-3556d73d3650815fb446dac33095d4be)
by [Unito](https://www.unito.io)
---------
Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
## Summary
Replace `useMouseInElement` in `CompareSliderThumbnail` with a local
`@mousemove` handler to eliminate ~1s of forced layout per keystroke in
the templates dialog search.
## Changes
- **What**: Profiling the templates dialog search (4× CPU throttle,
cloud distribution) showed a single `getClientRects` block at 977ms
inside Vue's `flushPostFlushCbs → update` path on every keystroke. The
call traces back to VueUse's `useMouseInElement`, which (a) shares a
global `mousemove` listener via `useMouse`, and (b) runs
`el.getBoundingClientRect()` inside a `watch([targetRef, x, y], …, {
immediate: true })`. Every template card with `thumbnailVariant ===
'compareSlider'` mounts one instance — when search filters change and
the page's compareSlider cards re-mount, each instance's `immediate`
watch fires a rect read against freshly-inserted DOM, forcing
synchronous layout of the entire new subtree. With many cards on screen
the costs stack into the ~977ms block. Replaced with a native
`@mousemove` listener bound directly to the slider container; the rect
is read from `event.currentTarget` only when the mouse is actually over
that one card, so the work no longer scales with mounted instance count
and is gated by real pointer activity.
- **Breaking**: None
- **Dependencies**: None
## Review Focus
- UX is unchanged: `sliderPosition` still follows the mouse during hover
and keeps its last value on mouseleave (matches previous behaviour where
`if (!isHovered) return` simply stopped updates without resetting).
- The same `useMouseInElement` pattern still exists in
`src/platform/workflow/sharing/composables/useSliderFromMouse.ts` (used
by `ComfyHubThumbnailStep` in the publish dialog). That path is
single-instance and off the templates hot path, so it's left untouched
to keep this PR scoped — happy to fix it in a follow-up.
- New tests use `userEvent.pointer({ coords })` with a stubbed
`getBoundingClientRect` to lock in the native mousemove path and the
zero-width guard.
## E2E coverage
No `browser_tests/` changes. The `fix:` commit is a defensive clamp
inside `updateSliderPosition`; the overshoot it guards against comes
from subpixel rounding and stale rects observed during hover-in, neither
of which can be deterministically reproduced under Playwright. The perf
change itself is verified via the DevTools profiler (forced-layout block
disappears) — also not assertable as a stable e2e signal across CI
hardware. Both the mousemove-driven slider behavior and the clamp guard
are covered by unit tests in
`src/components/templates/thumbnails/CompareSliderThumbnail.test.ts` (8
cases, including out-of-range pointer coordinates and zero-width
containers).
## Summary
Temporarily patch FE-569 by keeping the affected portaled Reka dropdowns
and menus above their containing PrimeVue dialogs when PrimeVue auto
z-index state has been elevated.
## Changes
- **What**: Added a small compatibility helper,
`usePrimeVueOverlayChildStyle`, that returns an anchor ref plus a
computed inline style for child popover content. The helper finds the
nearest PrimeVue dialog mask (`.p-dialog-mask` / `.p-overlay-mask`) from
the parent surface and, only when found, applies `parent z-index + 1` to
the affected Reka overlay content.
- **What**: Applied that helper at the exact PrimeVue parent surfaces
where the issue was found. This PR does not add a global overlay policy
and does not change every Reka select/dropdown in the app.
- **What**: Added optional `contentStyle`/`selectContentStyle` plumbing
only where needed so the style reaches the actual portaled Reka overlay
root.
- **What**: Added focused unit coverage for the helper contract: no
PrimeVue parent preserves existing stacking, PrimeVue dialog/overlay
masks render child content above the parent, low parent z-index values
respect the Reka floor, and invalid z-index values do not inject an
inline override.
- **Approach**: This is intentionally a minimal, parent-scoped band-aid.
It avoids a global PrimeVue overlay scanner because global sampling can
be polluted by unrelated persistent PrimeVue roots such as Toast and
would turn this fix into a broader layering policy.
- **Approach**: The patch targets the confirmed failure mode: a Reka
child overlay rendering below its owning PrimeVue dialog after PrimeVue
autoZIndex has been elevated. It does not attempt to solve PrimeVue
z-index globally.
- **Lifecycle**: This is temporary migration compatibility. PrimeVue
dialogs and controls are being incrementally migrated to Reka UI, so
`usePrimeVueOverlayChildStyle` and the optional style props added for
FE-569 should be removed once the affected parent surfaces move to Reka.
- **Breaking**: None. New props are optional and no public API contract
is changed.
- **Dependencies**: None.
## Patched Entry Points
This PR pinpoints the six affected user-facing surfaces below. Each
patch is applied from the PrimeVue dialog parent and passed only to the
Reka child overlay content that can render underneath that parent.
https://github.com/user-attachments/assets/d0d1522a-ffc7-4934-9e7a-06b83e20f809
1. **Workflow Template Library filters**
- **How to enter**: click the Templates button in the left sidebar, or
open the Comfy menu and choose **Browse Templates**.
- **Affected elements**: the template filter popovers in
`WorkflowTemplateSelectorDialog`: **Model**, **Use case**, **Runs on**,
and **Sort by**.
- **Patch point**: `WorkflowTemplateSelectorDialog.vue` anchors to the
template dialog content filter area and passes `selectContentStyle` to
the affected `MultiSelect` / `SingleSelect` controls.
https://github.com/user-attachments/assets/3641fa24-da51-4392-a904-9085f8a5a2f4
2. **Manager dialog header controls**
- **How to enter**: open Manager from the top/menu Manager entry when
the new Manager UI is available.
- **Affected elements**: the Manager header controls in `ManagerDialog`:
search mode `SingleSelect`, search autocomplete suggestions, and
**Sort** `SingleSelect`.
- **Patch point**: `ManagerDialog.vue` anchors to the dialog header and
passes `selectContentStyle` to those three Reka overlays.
https://github.com/user-attachments/assets/cf25cc06-f851-48ef-9d9c-9ec2da8afc06
3. **Asset Browser filter bar**
- **How to enter**: open the Asset Browser from an eligible model widget
browse action, the Model Library flow, or another
`useAssetBrowserDialog` caller.
- **Affected elements**: `AssetFilterBar` controls: **File formats**,
**Base models**, **Ownership**, and **Sort by**.
- **Patch point**: `AssetBrowserModal.vue` anchors to the PrimeVue
dialog header and passes the style through `AssetFilterBar` to its
`MultiSelect` / `SingleSelect` controls.
https://github.com/user-attachments/assets/e27bd805-10c0-4b3b-97f3-9e11faa47021
4. **Asset Browser model info panel**
- **How to enter**: open Asset Browser, select an asset, then use the
right-side model info panel.
- **Affected element**: the **Model type** select in `ModelInfoPanel`.
- **Patch point**: `AssetBrowserModal.vue` reuses the same parent-scoped
style and passes it to `ModelInfoPanel` as `selectContentStyle`.
https://github.com/user-attachments/assets/5e9f7ef0-ebd7-4987-ba1b-2137c034086f
5. **Upload Model confirmation step**
- **How to enter**: open Asset Browser, click **Upload**, enter/fetch
model metadata, then proceed to the confirmation step.
- **Affected element**: the **Model type** `SingleSelect` in
`UploadModelConfirmation`.
- **Patch point**: `UploadModelConfirmation.vue` anchors within the
upload dialog content and passes `selectContentStyle` to the model type
selector.
https://github.com/user-attachments/assets/ec145f26-8621-455b-915e-bedee47e1cbd
6. **Settings > Keybinding panel controls**
- **How to enter**: open Settings from the sidebar/menu, then select the
**Keybinding** panel.
- **Affected elements**: the keybinding preset select, the preset
overflow dropdown menu, and the row context menu inside
`KeybindingPanel`.
- **Patch point**: `KeybindingPanel.vue` anchors to the settings dialog
panel and passes `keybindingOverlayContentStyle` only to those Reka
overlay roots.
## Review Focus
- Confirm the patch stays narrowly scoped to the six known PrimeVue
parent + Reka child overlay surfaces above.
- Confirm `contentStyle` reaches the actual portaled Reka overlay
content in each patched path.
- Confirm the fallback behavior preserves existing stacking when no
PrimeVue parent overlay is found; in that case the helper returns an
empty style object and leaves existing Tailwind z-index classes alone.
- Please avoid expanding this into a larger overlay refactor. The goal
is a clean, backport-friendly compatibility patch while the Reka
migration continues.
Validation performed:
- `pnpm exec vitest run src/composables/usePopoverSizing.test.ts`
- `pnpm typecheck`
- `pnpm lint` (passes with existing unrelated warnings only)
- `pnpm format:check`
- commit hook lint-staged checks (`oxfmt`, `stylelint`, `oxlint`,
`eslint --fix`, `pnpm typecheck`)
- pre-push `pnpm knip`
Linear: FE-569
## Bug Screenshots
https://github.com/user-attachments/assets/e73761af-9867-4c50-ab0d-4e32e59011e1https://github.com/user-attachments/assets/145daf4d-3268-428b-9987-1e1afd0b866f
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12038-fix-keep-Reka-overlays-above-PrimeVue-dialogs-3596d73d365081e7af49dbc4d3905962)
by [Unito](https://www.unito.io)
## Root cause
`useAssetBrowser`'s `filteredAssets` computed re-ran
`.map(transformAssetForDisplay)` over the full result set on every tab
switch. `transformAssetForDisplay` allocates fresh `badges`/`stats`
objects, walks `tags`, calls `getAssetBaseModels`, and runs i18n date
formatting per asset — none of which were memoized. Switching All /
Inputs / Outputs forced N transforms per click and produced brand-new
`AssetDisplayItem` references, which also defeated `:key`-based diffing
in `AssetGrid` / `VirtualGrid` and re-rendered every visible card.
## Fix
Memoize `transformAssetForDisplay` at module scope with a
`WeakMap<AssetItem, AssetDisplayItem>`. Unchanged assets reuse the same
display item across tab switches; the GC reclaims entries when assets
are released.
## Before / after (n=200 assets, 6 tab switches: inputs → outputs → all
→ inputs → outputs → all)
| Metric | Before | After |
| ------------------------------- | -----: | ----: |
| `transformAssetForDisplay` runs | 800 | 0 |
| Wall time (Vitest harness) | 13.2 ms | 8.1 ms |
| Reused `AssetDisplayItem` refs | 0 | 200 |
Measured via
`src/platform/assets/composables/useAssetBrowser.perf.test.ts` plus a
temporary `process.stderr.write` harness.
## Red / green
| Commit | Purpose |
| --------- | ------- |
| 7367fdd60 | test: failing perf assertions (transform budget + reused
refs) |
| 021b98ac0 | perf: WeakMap memoization of `transformAssetForDisplay` |
## Test plan
- [x] `pnpm exec vitest run
src/platform/assets/composables/useAssetBrowser` — 42/42 pass (including
2 new perf assertions)
- [x] `pnpm typecheck`
- [x] `pnpm exec eslint` on touched files
- [x] `pnpm exec oxfmt --check` on touched files
Fixes [FE-229
](https://linear.app/comfyorg/issue/FE-229/asset-browser-switching-all-inputs-outputs-tabs-is-slow)Source:
https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1776716352588229
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11491-perf-memoize-asset-display-transform-across-filter-tab-switches-3496d73d36508112822dd6e7b58040fe)
by [Unito](https://www.unito.io)
---------
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
*PR Created by the Glary-Bot Agent*
---
Raises `Comfy.QueueButton.BatchCountLimit` on cloud from `32` to `100`
to match the server-side `MaxQueuedJobsPerUser` cap
(`cloud/infrastructure/dynamicconfig/prod/config.json:3`). The desktop
default was already `100` and is unchanged — collapsing both branches to
the same constant.
Addresses Discord feature request: [Increase queue batch limit from
200](https://discord.com/channels/1218270712402415686/1243609826299220039/1499104231381012641).
## Change
```diff
- defaultValue: isCloud ? 32 : 100,
+ defaultValue: 100,
```
The setting is read dynamically by all batch count UIs
(`BatchCountEdit.vue`, `LinearControls.vue`).
## Why 100 (not 512)
Original ask was 200→512. Investigation showed:
- The actual previous default was `100` (desktop) / `32` (cloud), not
200.
- Cloud enforces `MaxQueuedJobsPerUser = 100` per workspace server-side.
A higher frontend cap can't unlock more queued work — extra prompts just
get rejected with `QUEUE_LIMIT`.
- Frontend submits prompts as N sequential `POST /prompt` calls (no
batched-prompt endpoint), so the UI cap is purely about how many clicks
it takes — not throughput.
- Going from 32 → 100 lets cloud users match the server cap in one click
instead of 4. No new behavior is unlocked.
## Known limitation (pre-existing, not introduced here)
The new max equals the absolute server cap, not the user's remaining
capacity. A user with already-queued work can hit `QUEUE_LIMIT`
mid-batch. The pre-existing 32 limit had the same shape (just at a
smaller scale); deriving the UI max from `cap - outstanding` would
require polling and reactive state and is out of scope for a one-line
setting bump.
## Verification
- `pnpm typecheck` — passes
- `pnpm lint` — 0 errors (1 pre-existing warning in unrelated test file)
- `pnpm test:unit` — `BatchCountEdit.test.ts` (3 tests) +
`src/platform/settings/**` (70 tests) all pass
- **Manual (Playwright)**:
- `settingStore.get('Comfy.QueueButton.BatchCountLimit')` returns `100`
at runtime
- Typing `999` into the batch count widget clamps to `100`
- Increment button is disabled at `100` (max reached)
## Screenshots

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11876-feat-align-cloud-batch-count-limit-with-server-side-queue-cap-3566d73d3650819b8d01dbf83d1a8e49)
by [Unito](https://www.unito.io)
Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Replace PrimeVue components in 3D node viewer controls with the
project's Reka UI equivalents across 7 files.
## Changes
| File | Replaced |
|------|---------|
| `AnimationControls.vue` | `Select` × 2 (speed + animation) |
| `ViewerModelControls.vue` | `Select` × 2 (up direction + material
mode) |
| `ViewerCameraControls.vue` | `Select` + `Slider` (camera type + FOV) |
| `ViewerExportControls.vue` | `Select` (export format) |
| `PopupSlider.vue` | `Slider` |
| `ViewerLightControls.vue` | `Slider` |
| `ViewerSceneControls.vue` | `Checkbox` → native `<input
type="checkbox">` |
## Implementation notes
- `Select` uses `@/components/ui/select/*` compound components. Numeric
model values (animation speed index) are stringified at the binding
boundary and converted back on update, matching Reka `SelectRoot`'s
`string`-only `modelValue` contract.
- `Slider` uses `@/components/ui/slider/Slider.vue`. Single-number
`defineModel` values are wrapped in a `computed` array and unwrapped in
the update handler, following the pattern established in
`LightControls.vue`.
- No new Reka UI wrapper components were created — existing ui/select
and ui/slider primitives were used directly.
## Test
https://github.com/user-attachments/assets/afca0fc8-a7b6-49ee-b221-ee5725bd127e
1. AnimationControls.vue
- **Add Load3D node** → Upload an animated GLB file (e.g., a character
model).
- **Node preview top bar:** Play/Pause button, speed dropdown, animation
name dropdown, and progress bar.
2. PopupSlider.vue
- **Hover over Load3D preview:** Icon buttons appear in the left
toolbar.
- **"Light Intensity" button (bulb icon)** → Slider pops up on the
right.
- **"FOV" button (view icon)** → Slider pops up on the right.
3. ViewerCameraControls.vue
- **Load3D node** → Settings panel (top-right) → **"Camera"** tab.
- **Features:** Camera type dropdown (Perspective / Orthographic), FOV
slider (visible in Perspective mode).
4. ViewerExportControls.vue
- **Settings panel** → **"Export"** tab.
- **Features:** Format dropdown (GLB / OBJ / STL), Export button.
5. ViewerLightControls.vue
- **Settings panel** → **"Light"** tab.
- **Features:** Light intensity slider.
6. ViewerModelControls.vue
- **Settings panel** → **"Model"** tab.
- **Features:** "Up direction" dropdown, Material mode dropdown
(Wireframe / Normal, etc.).
7. ViewerSceneControls.vue
- **Settings panel** → **"Scene"** tab.
- **Features:** Background color picker, "Show grid" checkbox, upload
background image button.
<!-- CURSOR_SUMMARY -->
---
> [!NOTE]
> **Medium Risk**
> UI component swap touches multiple interactive viewer controls
(selects/sliders/checkbox), so small binding/typing differences (string
vs number, array slider values) could cause subtle regressions despite
test updates.
>
> **Overview**
> Replaces PrimeVue `Select`, `Slider`, and `Checkbox` usages across
Load3D viewer controls with the project’s Reka UI-based primitives
(`@/components/ui/select/*`, `@/components/ui/slider/Slider.vue`) and a
native checkbox.
>
> Updates v-model wiring to match the new components’ contracts: selects
now bind via string `modelValue` with explicit number casting where
needed, and sliders now wrap single numeric values into `[number]`
arrays with corresponding update handlers. Unit tests are updated to
mock the new UI components and their updated event/value shapes.
>
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
46f99db256. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12020-refactor-load3d-replace-PrimeVue-Select-Slider-Checkbox-with-Reka-UI-3586d73d365081f58601d93031016afd)
by [Unito](https://www.unito.io)
## Summary
Reuse the actionbar queue-button page object in the queue mode E2E tests
so dropdown selectors live in one helper.
## Changes
- **What**: Adds queue mode menu/item helpers to
`ComfyActionbar.queueButton` and updates `queueButtonModes.spec.ts` to
use them.
- **Dependencies**: None.
## Review Focus
This is stacked on #11209 and should be reviewed as a test-infra cleanup
only; the behavior covered by the spec is unchanged.
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11927-test-reuse-queue-button-page-object-in-e2e-3566d73d365081918d59c2d587c4c94a)
by [Unito](https://www.unito.io)
## Summary
Make `userStore.initialize()` idempotent and concurrency-safe so the
bootstrap, router-guard, and UserSelectView callers share a single
getUserConfig fetch instead of racing/duplicaitng calls.
## Changes
- **What**:
- cache initialize in a promise so callers all re-use the same result
- remove now redundant is initialized guard
- tests
## Review Focus
- Current user switch/logout uses `window.location.reload()`, no callers
intentionally call initialize to reinit. In future if this changes we
may want to add a parameter to skip the cache or a separate function.
- Failed initializes are not cached to allow callers to retry
- Not practical for e2e tests, the unit tests prove that the requests
are deduped. All a e2e test would do is mock/spy on the network requests
to show multiple requests do not happen - which the unit tests do a
better job of.
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11959-fix-add-guard-to-prevent-user-store-re-initialization-3576d73d3650817db7b0e52cc25f9b7b)
by [Unito](https://www.unito.io)
## Summary
- Defer thumbnail capture until camera state is restored via new
modelReady event so captureThumbnail no longer races with the saved
view, fixing the "snap back to default on hover" regression.
- Repaint the live scene at the end of captureThumbnail so the canvas is
not left with the offscreen mask/normal pass when the render loop is
gated.
- Persist post-fitToViewer model.scale + model.position into the
existing modelConfig.gizmo slot so a refresh reapplies them via the
existing applyGizmoConfigToLoad3d path; rotation stays owned by
upDirection.
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11944-FE-537-fix-load3d-preserve-camera-view-fit-transform-and-first-frame-paint-after-re-3576d73d365081429653ea4740612617)
by [Unito](https://www.unito.io)
Adds targeted behavioral tests for the slimmed `useBrushDrawing`
orchestration composable (Phase E of the brush-drawing refactor).
## Changes
- 5 new tests covering previously untested branches:
- `compositeStroke` receives `isRgb=true` when active layer is `rgb`
- `compositeStroke` receives `isErasing=true` when tool is `eraser`
- Mask canvas opacity is restored after drawing on the mask layer
- `globalCompositeOperation` is set to `destination-out` during
`handleDrawing` when tool is eraser
- `globalCompositeOperation` is set to `destination-out` during
`handleDrawing` when right mouse button is held
## Coverage (useBrushDrawing.ts)
| Metric | Before | After |
|--------|--------|-------|
| Statements | 86.33% | 87.05% |
| Branches | 68.75% | 70.00% |
| Functions | 90.00% | 90.00% |
| Lines | 89.23% | 90.00% |
All 18 tests pass. GPU paths remain `/* c8 ignore */` excluded
(untestable without WebGL).
- Fixes#0
<!-- CURSOR_SUMMARY -->
---
> [!NOTE]
> **Low Risk**
> Low risk: changes are limited to unit tests, adding coverage for
eraser/right-click composition and `drawEnd` GPU compositing/opacity
restoration paths without altering production logic.
>
> **Overview**
> Adds new `useBrushDrawing` test cases to cover previously untested
branches: setting `globalCompositeOperation` to `destination-out` during
`handleDrawing` when erasing (tool or right-click), and verifying
`drawEnd` passes correct `isRgb`/`isErasing` flags to
`gpu.compositeStroke`.
>
> Also asserts mask-layer opacity is restored after `drawEnd`,
increasing behavioral coverage around stroke completion and canvas
visibility cleanup.
>
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
20c411e6ce. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12001-test-maskeditor-expand-useBrushDrawing-behavioral-coverage-3586d73d365081388ebcef91c2172c0a)
by [Unito](https://www.unito.io)
## Summary
Add a `@perf` test measuring the cost of entering a subgraph containing
80 interior nodes. Establishes a CI baseline for the synchronous
mount/unmount bottleneck.
## Changes
- **What**: Add `subgraph transition (enter and exit)` perf test to
`performance.spec.ts` and a test workflow asset
(`large-subgraph-80-nodes.json`) with a single subgraph node containing
80 Note nodes.
## Review Focus
This is PR 1 of 2. The test establishes a baseline on main so the
optimization PR (PR 2) can show a CI-proven delta for `taskDurationMs`
and `totalBlockingTimeMs`.
The test:
1. Loads the 80-node subgraph workflow
2. Enters and exits once to warm up
3. Measures a fresh enter transition (start → 80 nodes mounted → layout
settled)
4. Records `taskDurationMs`, `layouts`, and `TBT`
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10480-test-add-perf-test-for-subgraph-transition-bottleneck-32d6d73d3650811b9b6eec03a9591f82)
by [Unito](https://www.unito.io)
---------
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Connor Byrne <c.byrne@comfy.org>
## Summary
Preview as Text (`PreviewAny`) nodes were re-executing on every prompt
submission because the rendered preview text was being echoed back to
the backend as input values, mutating the cache signature.
## Changes
- **What**: Set `widget.options.serialize = false` on the three widgets
the `Comfy.PreviewAny` extension adds (`preview_markdown`,
`preview_text`, `previewMode`) so they are excluded from the API prompt
sent to the backend.
## Root cause
The extension was setting `widget.serialize = false`, which only
controls **workflow JSON** persistence (checked in
`LGraphNode.serialize`). The **API prompt** serializer in
`executionUtil.graphToPrompt` checks `widget.options.serialize` instead
— a distinct property documented in litegraph's `WIDGET_SERIALIZATION`
convention.
After `onExecuted` writes the rendered text into the widget value, the
next `graphToPrompt` call serialized that text into
`inputs.preview_text` / `inputs.preview_markdown`. The backend cache
signature in `comfy_execution/caching.py` hashes all keys in
`node["inputs"]`, so the changing text invalidated the cache and forced
a redundant execution every time.
## Review Focus
Two commits, red-green TDD:
1. `test:` failing unit + e2e tests asserting the desired behavior.
2. `fix:` adds `options.serialize = false` to make them pass.
Tests verify the widgets are excluded from the API prompt; e2e
additionally simulates a prior execution populating widget values to
mirror the real bug condition.
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12010-fix-stop-PreviewAny-widgets-from-triggering-re-execution-3586d73d3650810585cdd077f3ac64f5)
by [Unito](https://www.unito.io)
2026-05-06 05:08:35 +00:00
354 changed files with 30327 additions and 1878 deletions
echo "::error::Tag '$TAG' implies version '$TAG_VERSION' but packages/extension-api/package.json has '$PKG_VERSION'. Update the package.json before tagging."
en:'Explore and Use a Community Workflow from the Hub',
'zh-CN':'探索并使用社区工作流'
},
'demos.community-workflows.description':{
en:'Discover how to find and get started with popular community workflows for generative AI projects.',
'zh-CN':'了解如何查找并使用流行的社区工作流来构建生成式 AI 项目。'
},
'demos.community-workflows.transcript':{
en:'<ol><li><strong>Open the Workflow Hub</strong> — From the ComfyUI sidebar, navigate to the community Workflow Hub to browse curated and trending workflows shared by the community.</li><li><strong>Browse popular workflows</strong> — Explore featured projects sorted by popularity, recency, and category to find one that matches your goal.</li><li><strong>Preview a workflow</strong> — Click a workflow card to see example outputs, required models, and a description of what it produces.</li><li><strong>Open in ComfyUI</strong> — Use the "Get Started" action to load the selected community workflow directly onto your canvas.</li><li><strong>Run and customize</strong> — Queue the workflow to generate your first result, then tweak prompts, models, and parameters to make it your own.</li></ol>',
@@ -249,6 +249,7 @@ Companion architecture documents that expand on the design in this ADR:
| [ECS Lifecycle Scenarios](../architecture/ecs-lifecycle-scenarios.md) | Before/after walkthroughs of lifecycle operations (node removal, link creation, etc.) |
| [World API and Command Layer](../architecture/ecs-world-command-api.md) | How each lifecycle scenario maps to a command in the World API |
| [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) | Design rationale for modeling subgraphs as node components, not separate entities |
| [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 |
| [Change Tracker](../architecture/change-tracker.md) | Documents the current undo/redo system that ECS cross-cutting concerns will replace |
| Promotion source types | `PromotedWidgetSource` and `PromotedWidgetView` carry the optional field. | Source identity needs more than immediate node ID plus widget name for nested promoted views. |
| Concrete widget resolution | `findWidgetByIdentity(...)` matches promoted views by `(disambiguatingSourceNodeId ?? sourceNodeId)` when a source node ID is supplied. | Multiple promoted views under the same intermediate node can share a widget name. |
| Legacy proxy normalization | Prefixed legacy names such as `123:widget_name` are converted into structured source identity and tested with candidate disambiguators. | Old serialized names encode leaf identity inside the widget name string. |
| Promotion store keys | `makePromotionEntryKey(...)`, `isPromoted(...)`, and `demote(...)` include the field in equality. | Store-level uniqueness would collapse distinct nested promotions without the leaf ID. |
| Linked promotion propagation | `SubgraphNode._resolveLinkedPromotionBySubgraphInput(...)` preserves the leaf ID when a linked input targets an inner subgraph promoted view. | The outer host otherwise sees only the immediate inner `SubgraphNode` and the promoted widget name. |
| Subgraph editor UI | The editor uses the field when resolving active widgets and when writing reordered/toggled promotions back to the store. | UI list operations must not merge same-name promoted views from different leaves. |
## New promoted-widget identity
ADR 0009 moves promoted value identity to the host boundary:
```ts
typePromotedWidgetUiIdentity={
hostNodeLocator: string
subgraphInputName: string
}
```
The canonical widget is owned by a `SubgraphInput` on the host
`SubgraphNode`. The host widget no longer needs to identify the deepest source
node to preserve value identity. The source widget is consulted for schema,
defaults, diagnostics, and migration, but it is not the value owner.
| `PromotedWidgetSource.disambiguatingSourceNodeId` | Host value identity is `hostNodeLocator + SubgraphInput.name`; source locator fields become migration/diagnostic metadata only. |
| `PromotedWidgetView.disambiguatingSourceNodeId` | Host-scoped widget entities are derived from subgraph inputs, not from promoted views chained through nested source widgets. |
| `findWidgetByIdentity(...)` leaf matching | Runtime value lookup starts from the host input identity; source traversal is metadata resolution, not value identity resolution. |
| Legacy prefixed widget-name normalization | Load migration consumes legacy source-shaped entries and writes standard subgraph input state or quarantine metadata. |
| Linked promotion propagation across nested hosts | Nested value composition is represented boundary-by-boundary by linked subgraph inputs with stable names. |
| Subgraph editor active widget matching | Editor state can operate on host boundary entries instead of matching leaf source widgets through same-name promoted views. |
## Boundary-by-boundary nested flow
The new form avoids flattened deep source paths. Each host boundary exposes its
own named input, and the next outer host links to that immediate boundary
| Serialized authority | `properties.proxyWidgets` stores source node/widget tuples as promotion topology. | Subgraph interface/input links can also represent the same exposed widget. | Subgraph interface/input links are the only canonical topology for promoted value widgets. |
| Load-time role | Hydrates promoted widgets directly from legacy tuples. | May already describe the promoted widget, creating overlap with `proxyWidgets`. | Existing linked inputs are accepted as resolved; legacy tuples are consumed by repair or quarantined. |
| Save-time role | Could be re-emitted as promotion state. | Serialized as normal subgraph interface data. | Repaired `proxyWidgets` entries are omitted; standard subgraph inputs plus host `widgets_values` are saved. |
| Value owner | Ambiguous: host `widgets_values` and the interior source widget could both carry the value. | Closer to the desired boundary model, but still coexisted with source/proxy ownership paths. | Host `SubgraphNode` owns value state through host-scoped widget identity. |
| Schema/default provider | Interior source widget provides schema and may also become persistence carrier. | Interior source widget provides source metadata through the link. | Interior source widget provides schema, type, options, tooltip, and defaults only. |
| UI identity | Often persisted by source node/widget identity. | Can use subgraph input identity, but mixed states still exist while proxy identity remains. | Host node locator plus `SubgraphInput.name`. |
| Display label handling | Source widget identity and display concerns can blur. | Uses existing subgraph input naming conventions. | `SubgraphInput.name` is stable identity; `label` / `localized_name` are display-only. |
| Multiple host instances | Risk of host instances stomping one another through shared interior values. | Better host boundary shape, but overlap with proxy/source value paths can reintroduce ambiguity. | Host-instance-owned sparse overlay prevents shared interior widget value stomping. |
| Prompt serialization | May read values through promoted runtime state that can collapse to source widgets. | Can serialize through standard subgraph input widgets when used consistently. | Promoted values serialize only through standard host-owned subgraph-input widgets. |
| Interior mutation on save | Existing `SubgraphNode.serialize()` behavior could copy exterior values into connected interior widgets. | Could still be affected by legacy copy-back behavior. | Copy-back is removed; source widgets are not persistence carriers. |
| Primitive-node promotions | Legacy tuples may point at `PrimitiveNode` outputs. | Not the canonical primitive fanout representation by itself. | Repaired all-or-nothing into one `SubgraphInput` that reconnects validated fanout targets. |
| Invalid or unresolved data | Invalid data could sit in legacy promotion state or fail repair paths. | Missing linked inputs can be ambiguous when proxy data exists. | Invalid raw data logs and is ignored; unrepaired valid value entries go to `proxyWidgetErrorQuarantine`. |
| Display-only previews | Often mixed into `proxyWidgets` despite not being value widgets. | Linked inputs are inappropriate because previews do not own values or prompt inputs. | Separate host-scoped `properties.previewExposures` entries model preview UI only. |
| Preview persistence | Preview selections can depend on source preview/widget-like identity. | No clean distinction from promoted widget inputs. | Preview identity is host node locator plus `previewName`; unresolved previews stay inert and persisted. |
| Nested preview behavior | Deep source identity can leak through host UI state. | Linked value inputs do not model display-only preview composition. | Preview exposures chain across immediate subgraph host boundaries; deep private paths are not persisted. |
| ECS compatibility | Weak: value identity can depend on source widget tuples and mutable interior widgets. | Partial: linked inputs fit boundary modeling, but duplicate authority remains. | Strong: host-scoped widget entity identity maps cleanly to ECS component state. |
| Long-term status | Legacy load-time input only. | Becomes the standard representation once overlap is removed. | Canonical system; `PromotionStore` becomes a temporary derived compatibility/index layer. |
| Valid `proxyWidgets` entry already represented by a linked `SubgraphInput` | Entry is consumed; the existing linked input remains canonical. |
| Valid value-widget `proxyWidgets` entry without a linked input | Repair creates or reconnects standard subgraph input/link state. |
| Valid primitive fanout entry | Repair creates one `SubgraphInput`, reconnects all validated targets, and leaves the primitive node inert. |
| Valid value-widget entry that cannot be repaired | Entry is persisted in `properties.proxyWidgetErrorQuarantine` with the host value when available. |
| Preview-shaped legacy entry | Entry is migrated into `properties.previewExposures`, not a linked input. |
| Unresolved preview exposure | Entry remains inert in `previewExposures`; it is not quarantined because it owns no user value. |
| Invalid raw `proxyWidgets` data | Logs `console.error`, does not throw, and is not quarantined. |
The v2 extension API initially included `node.on('beforeSerialize', handler)` as a migration path from v1 patterns like `node.onSerialize` and `nodeType.prototype.serialize` patching. This allowed extensions to:
1.**Append extra fields** to the serialized node object
2.**Transform the entire serialized object** via a replace function
However, during design review (PR #12142), we questioned whether node-level serialization control is the right abstraction:
### The Problem
Node-level serialization control is fundamentally **wrong-layered**:
- **Extension state should live in widgets**, not as arbitrary fields on the node
- Widget-level `beforeSerialize` already handles all legitimate use cases
- Node-level hooks encourage storing extension state in ad-hoc `node.properties` or custom fields, which:
- Breaks the clean separation between framework concerns and extension concerns
- Creates hidden dependencies between serialization format and extension behavior
- Makes migration and format evolution harder
### v1 Usage Analysis
Touch-point audit of `nodeType.prototype.serialize` and `node.onSerialize` patterns in the wild:
The extension API exposes collection-returning methods like `widgets()`, `inputs()`, `outputs()`, and object-returning methods like `getProperties()`. These methods need immutability guarantees to prevent extensions from accidentally or intentionally mutating internal state.
### The Problem
Without runtime immutability enforcement:
- Extensions could push items into `widgets()` array, corrupting internal state
- Mutations to returned objects would silently affect internal data
- Debugging would be difficult — state corruption could surface far from the mutation site
- Internal framework code might inadvertently rely on returned arrays being stable
TypeScript's `readonly` modifier and JSDoc annotations provide compile-time protection, but:
1.**Pure registration**: `defineNode()` has no side effects beyond pushing to an array. It doesn't touch the World, DOM, or any reactive state.
2.**Centralized activation**: `startExtensionSystem()` is called exactly once during app bootstrap. This single entry point controls when the extension system "goes live".
3.**Reactive mounting**: The loader watches the World for entity changes. Extensions are mounted/unmounted in response to ECS state, not imperative calls.
4.**Order independence**: Extensions can be defined in any order. The loader sorts by name (lexicographic, see D10b) for deterministic execution.
### Registration Flow
```
Extension files App bootstrap World
| | |
| defineNode({...}) | |
|--------------------->| |
| (push to array) | |
| | |
| | startExtensionSystem()
| |------------------>|
| | (watch for NodeType entities)
| | |
| | NodeType added |
| |<------------------|
| | |
| | mountExtensionsForNode(id)
| | (runs setup) |
```
## Consequences
### Positive
- **Testability**: Extensions are plain objects; tests can construct them without side effects. `_clearExtensionsForTesting()` resets state between tests.
- **Tree-shakeable**: Bundlers can eliminate unused extension files if their exports are never referenced.
- **Order independent**: No import order bugs — the loader handles activation order.
- **Lazy activation**: Registration is instant; activation only happens when `startExtensionSystem()` is called.
- **SSR friendly**: Pure functions don't execute browser-only code at import time.
### Negative
- **Manual bootstrap**: App must call `startExtensionSystem()` — forgetting it silently disables extensions.
- **Two-step mental model**: Developers must understand "register" vs "activate" phases.
### Mitigations
- App bootstrap is a well-defined location; the call is hard to miss.
- Clear documentation and starter templates include the bootstrap call.
- Dev-mode warnings if extensions are defined but the system never starts.
## Notes
This pattern aligns with modern framework conventions:
- **Vite plugins**: `vite.config.ts` collects plugins as an array; Vite activates them at build time.
- **Vue 3 Composition API**: `setup()` returns reactive state; the framework activates it.
- **React hooks**: Pure functions declare effects; React schedules them.
The key insight is separating **declaration** (what do I want?) from **execution** (make it happen). This separation enables testing, lazy loading, and predictable behavior.
| `widget.setHeight(px)` | Pixels | Widget height in pixels |
## Analysis
### When Extensions Need Canvas Coordinates
1.**Node positioning**: Placing nodes relative to each other
2.**Layout algorithms**: Auto-arranging nodes in a pattern
3.**Collision detection**: Checking if nodes overlap
### When Extensions Need Screen Coordinates
1.**Custom overlays**: Drawing UI at a specific screen location
2.**Drag-and-drop from external sources**: Converting mouse position to canvas position
3.**Context menus**: Positioning menus near the cursor
### Current State
The extension API currently exposes:
- **Canvas coordinates** for node position/size — appropriate, as these are logical values
- **Pixel values** for DOM widget height — appropriate, as these are DOM measurements
**Missing**: No conversion helpers between canvas and screen coordinates.
## Recommendation
**The current approach is appropriate.** Extensions that manipulate node positions should work in canvas space. This is the natural abstraction — extensions shouldn't need to account for zoom/pan when laying out nodes.
### For Advanced Cases
Extensions needing coordinate conversion (e.g., custom overlays) should either:
1.**Use LiteGraph's existing transform utilities** (available on `app.canvas`)
2.**Access the transform state** via a future canvas API (not part of node/widget handles)
### Why Not Expose Conversion Helpers on NodeHandle?
- **Wrong abstraction level**: Coordinate conversion is a canvas concern, not a node concern
- **State dependency**: Conversion requires current zoom/pan state, which changes frequently
- **Rare use case**: Most extensions work entirely in canvas space
## Future Considerations
If multiple extensions need coordinate conversion, consider:
1.**Single mental model**: Extensions learn one widget concept, not two.
2.**Consistent behavior**: All widgets appear in `node.widgets()`, serialize the same way.
3.**Simpler API surface**: Fewer methods to document and maintain.
### Arguments FOR Keeping Separate APIs
1.**Different ergonomics**: Standard widgets are data-driven (name, value, options); DOM widgets are element-driven (pass an HTMLElement).
2.**Type safety**: `addDOMWidget` can require `element: HTMLElement` at compile time; merging would make it optional with runtime checks.
3.**Clear intent**: Separate APIs signal different use cases.
## Recommendation
**Keep the current partial convergence.** The implementation is unified (`CreateWidget` command), but the creation APIs remain separate for ergonomic reasons.
### Rationale
1.**Creation differs, usage is unified.** Extensions create DOM widgets differently (need an element), but interact with them the same way (via `WidgetHandle`).
2.**Type safety is valuable.**`addDOMWidget({ element })` is clearer than `addWidget('DOM', name, null, { element })`.
3.**Already well-integrated.** DOM widgets appear in `node.widgets()`, get the same events, and use the same serialization infrastructure.
### What "Convergence" Means Here
The widgets are already converged at:
- **Entity level**: Same `WidgetEntityId` brand
- **Interface level**: Same `WidgetHandle` type
- **Command level**: Same `CreateWidget` command internally
The APIs are intentionally separate at:
- **Creation level**: `addWidget` vs `addDOMWidget`
This is the right split — unified where it matters (runtime behavior), separate where it improves DX (creation ergonomics).
## Future Considerations
If we add more widget creation patterns (e.g., `addCanvasWidget`, `addThreeJSWidget`), we might consider:
1.**Factory pattern**: `node.widgets.create('DOM', { element })` / `node.widgets.create('INT', { min, max })`
For now, two explicit methods (`addWidget`, `addDOMWidget`) serve the common cases well.
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.