Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions src/services/extensionService.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'

import { shouldLoadExtension } from './extensionService'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import type { ComfyExtension } from '@/types/comfy'

import { shouldLoadExtension, useExtensionService } from './extensionService'

describe('shouldLoadExtension', () => {
it.for(['/extensions/cloud/rum.js', '/extensions/cloud/sentry.js'])(
Expand All @@ -27,3 +31,31 @@ describe('shouldLoadExtension', () => {
)
})
})

describe('registerExtension', () => {
it('does not re-run registration side effects for a duplicate name', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const extension: ComfyExtension = {
name: 'dup.ext',
bottomPanelTabs: [
{
id: 'dup.tab',
title: 'Dup',
type: 'vue',
component: defineComponent({ render: () => null })
}
]
}

const extensionService = useExtensionService()
extensionService.registerExtension(extension)
extensionService.registerExtension(extension)

const bottomPanelStore = useBottomPanelStore()
expect(bottomPanelStore.panels.terminal.tabs).toHaveLength(1)
} finally {
warnSpy.mockRestore()
}
})
})
2 changes: 1 addition & 1 deletion src/services/extensionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export const useExtensionService = () => {
* @param extension The extension to register
*/
const registerExtension = (extension: ComfyExtension) => {
extensionStore.registerExtension(extension)
if (!extensionStore.registerExtension(extension)) return

const addKeybinding = wrapWithErrorHandling(
keybindingStore.addDefaultKeybinding
Expand Down
29 changes: 24 additions & 5 deletions src/stores/extensionStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,30 @@ describe('extensionStore', () => {
)
})

it('throws for duplicate registration', () => {
it('warns and keeps the first registration for a duplicate name', () => {
const store = useExtensionStore()
store.registerExtension({ name: 'dup' })
expect(() => store.registerExtension({ name: 'dup' })).toThrow(
"Extension named 'dup' already registered."
)
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const first = { name: 'dup' }
store.registerExtension(first)
expect(store.registerExtension({ name: 'dup' })).toBe(false)
Comment thread
mattmillerai marked this conversation as resolved.
expect(warnSpy).toHaveBeenCalledWith(
"Extension named 'dup' already registered - skipping"
)
expect(store.extensions).toHaveLength(1)
expect(store.extensions[0]).toBe(first)
} finally {
warnSpy.mockRestore()
}
})

it('registers names that collide with Object.prototype keys', () => {
const store = useExtensionStore()
store.registerExtension({ name: 'constructor' })
store.registerExtension({ name: '__proto__' })
expect(store.isExtensionInstalled('constructor')).toBe(true)
expect(store.isExtensionInstalled('__proto__')).toBe(true)
expect(store.extensions).toHaveLength(2)
})

it('warns when registering a disabled extension but still installs it', () => {
Expand All @@ -46,6 +64,7 @@ describe('extensionStore', () => {
it('returns false for uninstalled extension', () => {
const store = useExtensionStore()
expect(store.isExtensionInstalled('missing')).toBe(false)
expect(store.isExtensionInstalled('toString')).toBe(false)
})
})

Expand Down
29 changes: 19 additions & 10 deletions src/stores/extensionStore.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we just create a "isExtensionInstalled" primitive in safer cleaner way?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — done in f04c901. extensionByName is now a Map:

const extensionByName = ref<Map<string, ComfyExtension>>(new Map())
const isExtensionInstalled = (name: string) => extensionByName.value.has(name)

That drops the two things that made the previous version subtle — the Object.create(null) record and the Object.hasOwn call — because a Map has no prototype chain and no __proto__ special case, so the primitive is correct by construction rather than by defensive coding. registerExtension's duplicate check now calls isExtensionInstalled instead of repeating the lookup, so there's one definition of "installed" behind all three call sites.

Two things I verified rather than assumed:

  • Reactivity. Swapping a plain object for a Map behind a ref is the one real risk here, since Vue tracks collections through separate handlers. Confirmed the extensions computed still invalidates: reading extensions / isExtensionInstalled / hasThirdPartyExtensions before a registration and again afterwards returns updated values, so nothing goes stale.
  • Merge-queue safety. This branch predates test: provide a testing Pinia by default #15057 (global testing Pinia), which rewrites these exact test files, so I merged origin/main locally and ran the merged tree: 49 tests across extensionStore, extensionService, and the four extensions/core consumers pass. Dropped the merge afterwards since it wasn't needed to go green.

Incidental improvement: Object.values hoists integer-like keys ahead of insertion order, so an extension named "2" used to jump the list. Map preserves true insertion order.

Existing coverage carries over unchanged — registers names that collide with Object.prototype keys still guards the original finding, and still fails against a plain-object registry.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-requested your review, @christian-byrne. Leaving this thread open for you to close rather than closing it myself — per AGENTS.md, resolution on a non-trivial reviewer comment is the reviewer's call.

Nothing has changed on this since the reply above; the two commits after it are unrelated (a main merge and the CodeRabbit return-type nit). CI is green end to end.

Original file line number Diff line number Diff line change
Expand Up @@ -24,24 +24,24 @@ const ALWAYS_DISABLED_EXTENSIONS: readonly string[] = [
]

export const useExtensionStore = defineStore('extension', () => {
// For legacy reasons, the name uniquely identifies an extension
const extensionByName = ref<Record<string, ComfyExtension>>({})
const extensions = computed(() => Object.values(extensionByName.value))
// For legacy reasons, the name uniquely identifies an extension.
const extensionByName = ref<Map<string, ComfyExtension>>(new Map())
const extensions = computed(() => [...extensionByName.value.values()])
// Not using computed because disable extension requires reloading of the page.
// Dynamically update this list won't affect extensions that are already loaded.
const disabledExtensionNames = ref<Set<string>>(new Set())

const isExtensionInstalled = (name: string) => extensionByName.value.has(name)

// Disabled extension names that are currently not in the extension list.
// If a node pack is disabled in the backend, we shouldn't remove the configuration
// of the frontend extension disable list, in case the node pack is re-enabled.
const inactiveDisabledExtensionNames = computed(() => {
return Array.from(disabledExtensionNames.value).filter(
(name) => !(name in extensionByName.value)
(name) => !isExtensionInstalled(name)
)
})

const isExtensionInstalled = (name: string) => name in extensionByName.value

const isExtensionEnabled = (name: string) =>
!disabledExtensionNames.value.has(name)
const enabledExtensions = computed(() => {
Expand All @@ -55,20 +55,29 @@ export const useExtensionStore = defineStore('extension', () => {
)
}

function registerExtension(extension: ComfyExtension) {
/**
* Registers an extension by name. A name that is already registered keeps its
* first registration.
* @returns whether the extension was registered
*/
function registerExtension(extension: ComfyExtension): boolean {
if (!extension.name) {
throw new Error("Extensions must have a 'name' property.")
}

if (extensionByName.value[extension.name]) {
throw new Error(`Extension named '${extension.name}' already registered.`)
if (isExtensionInstalled(extension.name)) {
console.warn(
Comment thread
mattmillerai marked this conversation as resolved.
`Extension named '${extension.name}' already registered - skipping`
)
return false
}

if (disabledExtensionNames.value.has(extension.name)) {
console.warn(`Extension ${extension.name} is disabled.`)
}

extensionByName.value[extension.name] = markRaw(extension)
extensionByName.value.set(extension.name, markRaw(extension))
return true
}

function loadDisabledExtensionNames(names: string[]) {
Expand Down
Loading