mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-10 00:15:50 +00:00
Compare commits
4 Commits
jaeone/fe-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47118ef64f | ||
|
|
f110af79f7 | ||
|
|
8972d27689 | ||
|
|
72d1261983 |
@@ -31,9 +31,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useImage } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useImageQuiet } from '@/composables/useImageQuiet'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const {
|
||||
@@ -51,5 +51,5 @@ const {
|
||||
alt?: string
|
||||
}>()
|
||||
|
||||
const { error } = useImage(computed(() => ({ src, alt })))
|
||||
const { error } = useImageQuiet(computed(() => ({ src, alt })))
|
||||
</script>
|
||||
|
||||
27
src/composables/useImageQuiet.ts
Normal file
27
src/composables/useImageQuiet.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useImage } from '@vueuse/core'
|
||||
|
||||
/**
|
||||
* `useImage()` that handles load failures quietly.
|
||||
*
|
||||
* `useImage()` already surfaces failures via its returned `error` ref (callers
|
||||
* render a fallback). By default vueuse ALSO forwards the error to
|
||||
* `globalThis.reportError`, which our error monitoring (Datadog RUM) captures as
|
||||
* an unhandled error for every broken image — 404'd thumbnails, expired share
|
||||
* links, in-app browsers that re-fetch in a loop. Broken images are expected,
|
||||
* not bugs, so handle the failure here instead of letting it surface globally.
|
||||
* The returned `error` ref behaviour is unchanged.
|
||||
*
|
||||
* `asyncStateOptions` is forwarded to `useImage`, so callers can still tune the
|
||||
* other `useAsyncState` fields; only `onError` is fixed to the quiet default.
|
||||
*/
|
||||
export function useImageQuiet(
|
||||
options: Parameters<typeof useImage>[0],
|
||||
asyncStateOptions?: Parameters<typeof useImage>[1]
|
||||
) {
|
||||
return useImage(options, {
|
||||
...asyncStateOptions,
|
||||
onError: () => {
|
||||
// Surfaced via the returned `error` ref; see the doc comment above.
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -128,10 +128,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useImage } from '@vueuse/core'
|
||||
import { computed, ref, toValue, useId, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useImageQuiet } from '@/composables/useImageQuiet'
|
||||
import IconGroup from '@/components/button/IconGroup.vue'
|
||||
import MoreButton from '@/components/button/MoreButton.vue'
|
||||
import StatusBadge from '@/components/common/StatusBadge.vue'
|
||||
@@ -190,7 +190,7 @@ const tooltipDelay = computed<number>(() =>
|
||||
settingStore.get('LiteGraph.Node.TooltipDelay')
|
||||
)
|
||||
|
||||
const { isLoading, error } = useImage({
|
||||
const { isLoading, error } = useImageQuiet({
|
||||
src: asset.preview_url ?? '',
|
||||
alt: displayName.value
|
||||
})
|
||||
|
||||
@@ -20,8 +20,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useImage, whenever } from '@vueuse/core'
|
||||
import { whenever } from '@vueuse/core'
|
||||
|
||||
import { useImageQuiet } from '@/composables/useImageQuiet'
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
import { getAssetDisplayName } from '../utils/assetMetadataUtils'
|
||||
|
||||
@@ -34,7 +35,7 @@ const emit = defineEmits<{
|
||||
view: []
|
||||
}>()
|
||||
|
||||
const { state, error, isReady } = useImage({
|
||||
const { state, error, isReady } = useImageQuiet({
|
||||
src: asset.src ?? '',
|
||||
alt: getAssetDisplayName(asset)
|
||||
})
|
||||
|
||||
@@ -35,12 +35,17 @@ vi.mock('@/stores/workspace/sidebarTabStore', () => ({
|
||||
|
||||
let testId = 0
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.resetAllMocks()
|
||||
delete window.__comfyDesktop2
|
||||
delete window.__comfyDesktop2Remote
|
||||
})
|
||||
|
||||
describe('fetchModelMetadata', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset()
|
||||
mockIsDesktop.value = false
|
||||
mockSidebarTabStore.activeSidebarTabId = null
|
||||
mockStartDownload.mockReset()
|
||||
testId++
|
||||
})
|
||||
|
||||
@@ -242,7 +247,126 @@ describe('downloadModel', () => {
|
||||
beforeEach(() => {
|
||||
mockIsDesktop.value = false
|
||||
mockSidebarTabStore.activeSidebarTabId = null
|
||||
mockStartDownload.mockReset()
|
||||
})
|
||||
|
||||
it('uses the Desktop2 bridge directly instead of the browser fallback', () => {
|
||||
const anchorClick = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, 'click')
|
||||
.mockImplementation(() => {})
|
||||
const desktopDownloadModel = vi
|
||||
.fn<
|
||||
(url: string, filename: string, directory: string) => Promise<boolean>
|
||||
>()
|
||||
.mockResolvedValue(true)
|
||||
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
|
||||
|
||||
downloadModel(
|
||||
{
|
||||
name: 'model.safetensors',
|
||||
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
|
||||
directory: 'checkpoints'
|
||||
},
|
||||
{ checkpoints: ['/models/checkpoints'] }
|
||||
)
|
||||
|
||||
expect(desktopDownloadModel).toHaveBeenCalledWith(
|
||||
'https://huggingface.co/org/model/resolve/main/model.safetensors',
|
||||
'model.safetensors',
|
||||
'checkpoints'
|
||||
)
|
||||
expect(anchorClick).not.toHaveBeenCalled()
|
||||
expect(mockStartDownload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('logs Desktop2 bridge failures without falling back to browser download', async () => {
|
||||
const anchorClick = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, 'click')
|
||||
.mockImplementation(() => {})
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const bridgeError = new Error('bridge failed')
|
||||
const desktopDownloadModel = vi
|
||||
.fn<
|
||||
(url: string, filename: string, directory: string) => Promise<boolean>
|
||||
>()
|
||||
.mockRejectedValue(bridgeError)
|
||||
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
|
||||
|
||||
downloadModel(
|
||||
{
|
||||
name: 'model.safetensors',
|
||||
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
|
||||
directory: 'checkpoints'
|
||||
},
|
||||
{ checkpoints: ['/models/checkpoints'] }
|
||||
)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'Failed to start Desktop2 model download:',
|
||||
bridgeError
|
||||
)
|
||||
})
|
||||
expect(anchorClick).not.toHaveBeenCalled()
|
||||
expect(mockStartDownload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('logs synchronous Desktop2 bridge failures without crashing', async () => {
|
||||
const anchorClick = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, 'click')
|
||||
.mockImplementation(() => {})
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const bridgeError = new Error('bridge failed before returning a promise')
|
||||
const desktopDownloadModel = vi
|
||||
.fn<
|
||||
(url: string, filename: string, directory: string) => Promise<boolean>
|
||||
>()
|
||||
.mockImplementation(() => {
|
||||
throw bridgeError
|
||||
})
|
||||
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
|
||||
|
||||
downloadModel(
|
||||
{
|
||||
name: 'model.safetensors',
|
||||
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
|
||||
directory: 'checkpoints'
|
||||
},
|
||||
{ checkpoints: ['/models/checkpoints'] }
|
||||
)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'Failed to start Desktop2 model download:',
|
||||
bridgeError
|
||||
)
|
||||
})
|
||||
expect(anchorClick).not.toHaveBeenCalled()
|
||||
expect(mockStartDownload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps remote Desktop2 sessions on the browser fallback', () => {
|
||||
const anchorClick = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, 'click')
|
||||
.mockImplementation(() => {})
|
||||
const desktopDownloadModel = vi
|
||||
.fn<
|
||||
(url: string, filename: string, directory: string) => Promise<boolean>
|
||||
>()
|
||||
.mockResolvedValue(true)
|
||||
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
|
||||
window.__comfyDesktop2Remote = true
|
||||
|
||||
downloadModel(
|
||||
{
|
||||
name: 'model.safetensors',
|
||||
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
|
||||
directory: 'checkpoints'
|
||||
},
|
||||
{ checkpoints: ['/models/checkpoints'] }
|
||||
)
|
||||
|
||||
expect(desktopDownloadModel).not.toHaveBeenCalled()
|
||||
expect(anchorClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('opens the model library sidebar before starting a desktop download', () => {
|
||||
|
||||
@@ -3,6 +3,21 @@ import { isDesktop } from '@/platform/distribution/types'
|
||||
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
|
||||
interface ComfyDesktop2Bridge {
|
||||
downloadModel: (
|
||||
url: string,
|
||||
filename: string,
|
||||
directory: string
|
||||
) => Promise<boolean>
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__comfyDesktop2?: ComfyDesktop2Bridge
|
||||
__comfyDesktop2Remote?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const ALLOWED_SOURCES = [
|
||||
'https://civitai.com/',
|
||||
'https://civitai.red/',
|
||||
@@ -35,6 +50,17 @@ export interface ModelWithUrl {
|
||||
directory: string
|
||||
}
|
||||
|
||||
async function startDesktop2ModelDownload(
|
||||
bridge: ComfyDesktop2Bridge,
|
||||
model: ModelWithUrl
|
||||
): Promise<void> {
|
||||
try {
|
||||
await bridge.downloadModel(model.url, model.name, model.directory)
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to start Desktop2 model download:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a model download URL to a browsable page URL.
|
||||
* - HuggingFace: `/resolve/` → `/blob/` (file page with model info)
|
||||
@@ -63,6 +89,12 @@ export function downloadModel(
|
||||
model: ModelWithUrl,
|
||||
paths: Record<string, string[]>
|
||||
): void {
|
||||
const desktop2Bridge = window.__comfyDesktop2
|
||||
if (desktop2Bridge?.downloadModel && !window.__comfyDesktop2Remote) {
|
||||
void startDesktop2ModelDownload(desktop2Bridge, model)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isDesktop) {
|
||||
const link = document.createElement('a')
|
||||
link.href = model.url
|
||||
|
||||
@@ -208,6 +208,23 @@ describe('GtmTelemetryProvider', () => {
|
||||
expect(entry!.error as string).toHaveLength(100)
|
||||
})
|
||||
|
||||
it('pushes execution_start', () => {
|
||||
const provider = createInitializedProvider()
|
||||
provider.trackWorkflowExecution()
|
||||
expect(lastDataLayerEntry()).toMatchObject({
|
||||
event: 'execution_start'
|
||||
})
|
||||
})
|
||||
|
||||
it('pushes execution_success with job_id', () => {
|
||||
const provider = createInitializedProvider()
|
||||
provider.trackExecutionSuccess({ jobId: 'job-1' })
|
||||
expect(lastDataLayerEntry()).toMatchObject({
|
||||
event: 'execution_success',
|
||||
job_id: 'job-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('pushes select_content for template events', () => {
|
||||
const provider = createInitializedProvider()
|
||||
provider.trackTemplate({
|
||||
|
||||
@@ -59,8 +59,6 @@ import type {
|
||||
AuthMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ExecutionErrorMetadata,
|
||||
ExecutionSuccessMetadata,
|
||||
ShareFlowMetadata,
|
||||
SurveyResponses,
|
||||
TemplateLibraryClosedMetadata,
|
||||
@@ -288,8 +286,6 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
|
||||
}
|
||||
const enterLinearMetadata: EnterLinearMetadata = {}
|
||||
const shareFlowMetadata: ShareFlowMetadata = { step: 'dialog_opened' }
|
||||
const executionErrorMetadata: ExecutionErrorMetadata = { jobId: 'job-1' }
|
||||
const executionSuccessMetadata: ExecutionSuccessMetadata = { jobId: 'job-1' }
|
||||
const authMetadata: AuthMetadata = {}
|
||||
|
||||
it.for<
|
||||
@@ -355,16 +351,6 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
|
||||
(p) => p.trackShareFlow(shareFlowMetadata),
|
||||
TelemetryEvents.SHARE_FLOW
|
||||
],
|
||||
[
|
||||
'trackExecutionError',
|
||||
(p) => p.trackExecutionError(executionErrorMetadata),
|
||||
TelemetryEvents.EXECUTION_ERROR
|
||||
],
|
||||
[
|
||||
'trackExecutionSuccess',
|
||||
(p) => p.trackExecutionSuccess(executionSuccessMetadata),
|
||||
TelemetryEvents.EXECUTION_SUCCESS
|
||||
],
|
||||
[
|
||||
'trackAuth',
|
||||
(p) => p.trackAuth(authMetadata),
|
||||
@@ -422,27 +408,6 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('trackWorkflowExecution forwards the latest trigger_source from trackRunButton', async () => {
|
||||
const provider = new MixpanelTelemetryProvider()
|
||||
await waitForMixpanelInit()
|
||||
mockMixpanel.track.mockClear()
|
||||
|
||||
provider.trackRunButton({ trigger_source: 'keybinding' })
|
||||
provider.trackWorkflowExecution()
|
||||
|
||||
expect(mockMixpanel.track).toHaveBeenCalledWith(
|
||||
TelemetryEvents.EXECUTION_START,
|
||||
expect.objectContaining({ trigger_source: 'keybinding' })
|
||||
)
|
||||
|
||||
mockMixpanel.track.mockClear()
|
||||
provider.trackWorkflowExecution()
|
||||
expect(mockMixpanel.track).toHaveBeenCalledWith(
|
||||
TelemetryEvents.EXECUTION_START,
|
||||
expect.objectContaining({ trigger_source: 'unknown' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MixpanelTelemetryProvider — topup delegation', () => {
|
||||
|
||||
@@ -18,10 +18,7 @@ import type {
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ShareFlowMetadata,
|
||||
ExecutionContext,
|
||||
ExecutionTriggerSource,
|
||||
ExecutionErrorMetadata,
|
||||
ExecutionSuccessMetadata,
|
||||
HelpCenterClosedMetadata,
|
||||
HelpCenterOpenedMetadata,
|
||||
HelpResourceClickedMetadata,
|
||||
@@ -92,7 +89,6 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
private mixpanel: OverridedMixpanel | null = null
|
||||
private eventQueue: QueuedEvent[] = []
|
||||
private isInitialized = false
|
||||
private lastTriggerSource: ExecutionTriggerSource | undefined
|
||||
private disabledEvents = new Set<TelemetryEventName>(DEFAULT_DISABLED_EVENTS)
|
||||
|
||||
constructor() {
|
||||
@@ -300,7 +296,6 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
is_app_mode: isAppMode.value
|
||||
}
|
||||
|
||||
this.lastTriggerSource = options?.trigger_source
|
||||
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, runButtonProperties)
|
||||
}
|
||||
|
||||
@@ -420,24 +415,6 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.WORKFLOW_CREATED, metadata)
|
||||
}
|
||||
|
||||
trackWorkflowExecution(): void {
|
||||
const context = getExecutionContext()
|
||||
const eventContext: ExecutionContext = {
|
||||
...context,
|
||||
trigger_source: this.lastTriggerSource ?? 'unknown'
|
||||
}
|
||||
this.trackEvent(TelemetryEvents.EXECUTION_START, eventContext)
|
||||
this.lastTriggerSource = undefined
|
||||
}
|
||||
|
||||
trackExecutionError(metadata: ExecutionErrorMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.EXECUTION_ERROR, metadata)
|
||||
}
|
||||
|
||||
trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.EXECUTION_SUCCESS, metadata)
|
||||
}
|
||||
|
||||
trackSettingChanged(metadata: SettingChangedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.SETTING_CHANGED, metadata)
|
||||
}
|
||||
|
||||
@@ -14,9 +14,6 @@ import type {
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ShareFlowMetadata,
|
||||
ExecutionContext,
|
||||
ExecutionErrorMetadata,
|
||||
ExecutionSuccessMetadata,
|
||||
ExecutionTriggerSource,
|
||||
HelpCenterClosedMetadata,
|
||||
HelpCenterOpenedMetadata,
|
||||
@@ -102,7 +99,6 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
private eventQueue: QueuedEvent[] = []
|
||||
private pendingFirstAuthAt = new Map<string, string>()
|
||||
private isInitialized = false
|
||||
private lastTriggerSource: ExecutionTriggerSource | undefined
|
||||
private disabledEvents = new Set<TelemetryEventName>(DEFAULT_DISABLED_EVENTS)
|
||||
private desktopEntryProps: DesktopEntryProps | null = null
|
||||
|
||||
@@ -400,7 +396,6 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
is_app_mode: isAppMode.value
|
||||
}
|
||||
|
||||
this.lastTriggerSource = options?.trigger_source
|
||||
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, runButtonProperties)
|
||||
}
|
||||
|
||||
@@ -532,24 +527,6 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.WORKFLOW_CREATED, metadata)
|
||||
}
|
||||
|
||||
trackWorkflowExecution(): void {
|
||||
const context = getExecutionContext()
|
||||
const eventContext: ExecutionContext = {
|
||||
...context,
|
||||
trigger_source: this.lastTriggerSource ?? 'unknown'
|
||||
}
|
||||
this.trackEvent(TelemetryEvents.EXECUTION_START, eventContext)
|
||||
this.lastTriggerSource = undefined
|
||||
}
|
||||
|
||||
trackExecutionError(metadata: ExecutionErrorMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.EXECUTION_ERROR, metadata)
|
||||
}
|
||||
|
||||
trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.EXECUTION_SUCCESS, metadata)
|
||||
}
|
||||
|
||||
trackSettingChanged(metadata: SettingChangedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.SETTING_CHANGED, metadata)
|
||||
}
|
||||
|
||||
@@ -28,10 +28,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useImage } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
|
||||
import { useImageQuiet } from '@/composables/useImageQuiet'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { name, previewUrl } = defineProps<{
|
||||
@@ -63,5 +63,5 @@ const imageOptions = computed(() => ({
|
||||
src: normalizedPreviewUrl.value ?? ''
|
||||
}))
|
||||
|
||||
const { isReady, isLoading, error } = useImage(imageOptions)
|
||||
const { isReady, isLoading, error } = useImageQuiet(imageOptions)
|
||||
</script>
|
||||
|
||||
@@ -38,6 +38,15 @@ describe('widgetStore', () => {
|
||||
store.registerCustomWidgets({ INT: override })
|
||||
expect(store.widgets.get('INT')).toBe(ComfyWidgets.INT)
|
||||
})
|
||||
|
||||
it('does not throw when an extension returns null/undefined widgets', () => {
|
||||
const store = useWidgetStore()
|
||||
// Regression: a misbehaving extension can resolve getCustomWidgets() to
|
||||
// nullish, which must not break app init. The `!` casts deliberately
|
||||
// violate the non-null parameter type to simulate that untrusted input.
|
||||
expect(() => store.registerCustomWidgets(undefined!)).not.toThrow()
|
||||
expect(() => store.registerCustomWidgets(null!)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('inputIsWidget', () => {
|
||||
|
||||
@@ -22,6 +22,11 @@ export const useWidgetStore = defineStore('widget', () => {
|
||||
function registerCustomWidgets(
|
||||
newWidgets: Record<string, ComfyWidgetConstructor>
|
||||
) {
|
||||
// Extensions are untrusted code: `getCustomWidgets` is typed to return
|
||||
// `Record<string, ...>`, but in practice an extension can resolve it to
|
||||
// null/undefined. Guard here so a single misbehaving custom node can't
|
||||
// throw "Cannot convert undefined or null to object" and break app init.
|
||||
if (!newWidgets) return
|
||||
for (const [type, widget] of Object.entries(newWidgets)) {
|
||||
customWidgets.value.set(type, widget)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user