Compare commits

...

4 Commits

Author SHA1 Message Date
Robin Huang
fef7fd9d25 fix: harden manager survey resize, late config, and timeout states
Address PR review:
- Recover from the error state when the survey URL arrives after the
  dialog opens (reactive load state instead of a one-time seed).
- Tolerate a trailing slash in the configured URL when extracting the
  survey id for the resize message match.
- Unmount the iframe in the error/timeout state so the fallback UI no
  longer stacks beneath a blank frame.
2026-06-26 16:58:46 -07:00
Robin Huang
1fd79dfe0d fix: align survey embed with PostHog's official snippet
Add embed=true to the hosted survey URL and switch the auto-resize
listener to PostHog's survey message format (posthog:survey:height +
surveyId, with a height bounds check) so the iframe resizes correctly.
2026-06-26 11:44:18 -07:00
Robin Huang
1b36f07bac fix: sandbox survey iframe and guard malformed survey url
Address PR review: restrict the embedded survey iframe with a minimal
sandbox, and fall back to the error state when the configured
manager_survey_url is malformed instead of throwing.
2026-06-26 11:38:59 -07:00
Robin Huang
46ceec8021 feat: add custom nodes waitlist survey to Manager button on Cloud
Show the Manager button on Comfy Cloud and open a hosted survey in the
manager modal. The hosted survey URL is provided per environment by cloud
config (manager_survey_url) and embedded via iframe, with the logged-in
user's distinct_id appended so responses link to the user.
2026-06-24 20:28:33 -07:00
7 changed files with 397 additions and 1 deletions

View File

@@ -13,7 +13,7 @@
<div class="mx-1 flex flex-col items-end gap-1">
<div class="flex items-center gap-2">
<div
v-if="managerState.shouldShowManagerButtons.value"
v-if="managerState.shouldShowManagerButtons.value || isCloud"
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
>
<Button
@@ -163,6 +163,7 @@ import {
} from '@/platform/workflow/sharing/composables/lazyShareDialog'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { useManagerSurveyDialog } from '@/workbench/extensions/manager/composables/useManagerSurveyDialog'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import { cn } from '@comfyorg/tailwind-utils'
@@ -170,6 +171,7 @@ const settingStore = useSettingStore()
const workspaceStore = useWorkspaceStore()
const rightSidePanelStore = useRightSidePanelStore()
const managerState = useManagerState()
const managerSurveyDialog = useManagerSurveyDialog()
const { flags } = useFeatureFlags()
const { isLoggedIn } = useCurrentUser()
const { t } = useI18n()
@@ -337,6 +339,10 @@ onBeforeUnmount(() => {
})
const openCustomNodeManager = async () => {
if (isCloud) {
managerSurveyDialog.show()
return
}
try {
await managerState.openManager({
initialTab: ManagerTab.All,

View File

@@ -389,6 +389,11 @@
},
"manager": {
"title": "Nodes Manager",
"survey": {
"title": "Join the waitlist",
"intro": "Installing custom nodes is coming soon to Comfy Cloud. Answer a few quick questions to join the waitlist and help shape this feature.",
"error": "This survey couldn't be loaded. Please try again later."
},
"nodePackInfo": "Node Pack Info",
"basicInfo": "Basic Info",
"actions": "Actions",

View File

@@ -100,6 +100,8 @@ export type RemoteConfig = {
private_models_enabled?: boolean
onboarding_survey_enabled?: boolean
onboarding_survey?: OnboardingSurvey
/** Full hosted (external) survey URL embedded in the Nodes Manager modal on Cloud. */
manager_survey_url?: string
linear_toggle_enabled?: boolean
team_workspaces_enabled?: boolean
user_secrets_enabled?: boolean

View File

@@ -0,0 +1,165 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
const mocks = vi.hoisted(
() =>
({
remoteConfig: { value: {} },
resolvedUserInfo: { value: null }
}) as {
remoteConfig: { value: RemoteConfig }
resolvedUserInfo: { value: { id: string } | null }
}
)
vi.mock('@/platform/remoteConfig/remoteConfig', async () => {
const { ref } = await import('vue')
mocks.remoteConfig = ref<RemoteConfig>({})
return { remoteConfig: mocks.remoteConfig }
})
vi.mock('@/composables/auth/useCurrentUser', async () => {
const { ref } = await import('vue')
mocks.resolvedUserInfo = ref<{ id: string } | null>(null)
return {
useCurrentUser: () => ({ resolvedUserInfo: mocks.resolvedUserInfo })
}
})
import ManagerSurveyDialog from '@/workbench/extensions/manager/components/survey/ManagerSurveyDialog.vue'
const SURVEY_URL = 'https://us.posthog.com/external_surveys/survey-123'
function renderDialog(onClose = vi.fn()) {
return render(ManagerSurveyDialog, {
props: { onClose },
global: { mocks: { $t: (key: string) => key } }
})
}
describe('ManagerSurveyDialog', () => {
beforeEach(() => {
mocks.remoteConfig.value = {}
mocks.resolvedUserInfo.value = null
})
it('embeds the configured survey URL with embed flag and the logged-in user', () => {
mocks.remoteConfig.value = { manager_survey_url: SURVEY_URL }
mocks.resolvedUserInfo.value = { id: 'user-123' }
renderDialog()
const src = new URL(
screen.getByTestId('manager-survey-iframe').getAttribute('src')!
)
expect(src.origin + src.pathname).toBe(SURVEY_URL)
expect(src.searchParams.get('embed')).toBe('true')
expect(src.searchParams.get('distinct_id')).toBe('user-123')
})
it('omits distinct_id when there is no logged-in user', () => {
mocks.remoteConfig.value = { manager_survey_url: SURVEY_URL }
renderDialog()
const src = new URL(
screen.getByTestId('manager-survey-iframe').getAttribute('src')!
)
expect(src.searchParams.has('distinct_id')).toBe(false)
})
it('shows the error state when no survey url is configured', () => {
renderDialog()
expect(screen.queryByTestId('manager-survey-iframe')).toBeNull()
expect(screen.getByTestId('manager-survey-error')).toBeTruthy()
})
it('shows the error state when the configured survey url is malformed', () => {
mocks.remoteConfig.value = { manager_survey_url: 'not a valid url' }
renderDialog()
expect(screen.queryByTestId('manager-survey-iframe')).toBeNull()
expect(screen.getByTestId('manager-survey-error')).toBeTruthy()
})
it('recovers from the error state when the survey url arrives after mount', async () => {
renderDialog()
expect(screen.getByTestId('manager-survey-error')).toBeTruthy()
mocks.remoteConfig.value = { manager_survey_url: SURVEY_URL }
await nextTick()
expect(screen.queryByTestId('manager-survey-error')).toBeNull()
expect(screen.getByTestId('manager-survey-iframe')).toBeTruthy()
})
it('clears the loading state once the iframe loads', async () => {
mocks.remoteConfig.value = { manager_survey_url: SURVEY_URL }
renderDialog()
expect(screen.getByTestId('manager-survey-loading')).toBeTruthy()
await screen
.getByTestId('manager-survey-iframe')
.dispatchEvent(new Event('load'))
expect(screen.queryByTestId('manager-survey-loading')).toBeNull()
})
it('applies survey height messages even when the url has a trailing slash', async () => {
mocks.remoteConfig.value = {
manager_survey_url: 'https://us.posthog.com/external_surveys/survey-123/'
}
renderDialog()
const iframe = screen.getByTestId('manager-survey-iframe')
window.dispatchEvent(
new MessageEvent('message', {
origin: 'https://us.posthog.com',
data: {
type: 'posthog:survey:height',
surveyId: 'survey-123',
height: 800
}
})
)
await nextTick()
expect(iframe.getAttribute('style')).toContain('height: 800px')
})
it('removes the iframe and shows the error state when loading times out', async () => {
vi.useFakeTimers()
try {
mocks.remoteConfig.value = { manager_survey_url: SURVEY_URL }
renderDialog()
expect(screen.getByTestId('manager-survey-iframe')).toBeTruthy()
await vi.advanceTimersByTimeAsync(8000)
await nextTick()
expect(screen.queryByTestId('manager-survey-iframe')).toBeNull()
expect(screen.getByTestId('manager-survey-error')).toBeTruthy()
} finally {
vi.useRealTimers()
}
})
it('closes the dialog when the close button is clicked', async () => {
const onClose = vi.fn()
mocks.remoteConfig.value = { manager_survey_url: SURVEY_URL }
renderDialog(onClose)
await userEvent.click(screen.getByRole('button', { name: 'g.close' }))
expect(onClose).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,150 @@
<template>
<div class="flex w-full flex-col">
<header
class="flex h-12 items-center justify-between gap-2 border-b border-border-default px-4"
>
<div class="flex items-center gap-2">
<i class="icon-[comfy--extensions-blocks]" />
<h2 class="text-neutral m-0 text-base">
{{ $t('manager.survey.title') }}
</h2>
</div>
<Button size="icon" :aria-label="$t('g.close')" @click="onClose">
<i class="icon-[lucide--x] size-4" />
</Button>
</header>
<main class="px-5 py-4">
<p
v-if="status !== 'error'"
class="mt-1 mb-4 text-sm text-muted-foreground"
>
{{ $t('manager.survey.intro') }}
</p>
<div class="relative">
<iframe
v-if="surveyUrl && status !== 'error'"
:src="surveyUrl"
:title="$t('manager.survey.title')"
:style="{ height: `${iframeHeight}px` }"
sandbox="allow-scripts allow-same-origin allow-forms"
data-testid="manager-survey-iframe"
class="w-full rounded-lg border-0"
@load="onIframeLoad"
/>
<div
v-if="status === 'loading'"
data-testid="manager-survey-loading"
class="pointer-events-none absolute inset-0 flex items-center justify-center"
>
<i
class="icon-[lucide--loader-2] size-6 animate-spin text-muted-foreground"
/>
</div>
<div
v-else-if="status === 'error'"
data-testid="manager-survey-error"
class="flex min-h-56 flex-col items-center justify-center gap-2 text-center"
>
<i
class="icon-[lucide--triangle-alert] size-6 text-muted-foreground"
/>
<p class="m-0 text-sm text-muted-foreground">
{{ $t('manager.survey.error') }}
</p>
</div>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { useEventListener, useTimeoutFn } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
const DEFAULT_IFRAME_HEIGHT = 460
const MAX_IFRAME_HEIGHT = 10000
const SURVEY_LOAD_TIMEOUT_MS = 8000
defineProps<{
onClose: () => void
}>()
const { resolvedUserInfo } = useCurrentUser()
// The hosted (external) survey URL is provided per environment by cloud config.
const surveyUrl = computed(() => {
const base = remoteConfig.value.manager_survey_url
if (!base) return undefined
try {
const url = new URL(base)
url.searchParams.set('embed', 'true')
// Link responses to the logged-in user; omit for anonymous responses.
const distinctId = resolvedUserInfo.value?.id
if (distinctId) url.searchParams.set('distinct_id', distinctId)
return url.toString()
} catch {
// A malformed configured URL falls back to the error state.
return undefined
}
})
const parsedSurveyUrl = computed(() =>
surveyUrl.value ? new URL(surveyUrl.value) : undefined
)
const surveyOrigin = computed(() => parsedSurveyUrl.value?.origin)
// filter(Boolean) tolerates a trailing slash in the configured URL.
const surveyId = computed(() =>
parsedSurveyUrl.value?.pathname.split('/').filter(Boolean).pop()
)
const status = ref<'loading' | 'ready' | 'error'>('loading')
const iframeHeight = ref(DEFAULT_IFRAME_HEIGHT)
const { start: startLoadTimeout, stop: stopLoadTimeout } = useTimeoutFn(
() => {
if (status.value === 'loading') status.value = 'error'
},
SURVEY_LOAD_TIMEOUT_MS,
{ immediate: false }
)
// Re-derive load state whenever the survey URL changes — e.g. cloud config or
// the user identity arriving after the dialog opens — so a late URL recovers
// from the error state instead of staying stuck.
watch(
surveyUrl,
(url) => {
if (!url) {
stopLoadTimeout()
status.value = 'error'
return
}
status.value = 'loading'
startLoadTimeout()
},
{ immediate: true }
)
const onIframeLoad = () => {
if (status.value === 'loading') status.value = 'ready'
stopLoadTimeout()
}
// PostHog's embedded survey posts its content height for auto-resizing.
useEventListener(window, 'message', (event: MessageEvent) => {
if (event.origin !== surveyOrigin.value) return
const data = event.data
if (
data?.type === 'posthog:survey:height' &&
data?.surveyId === surveyId.value
) {
const height = Number.parseInt(data.height, 10)
if (height > 0 && height < MAX_IFRAME_HEIGHT) iframeHeight.value = height
}
})
</script>

View File

@@ -0,0 +1,36 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const showDialog = vi.hoisted(() => vi.fn())
const closeDialog = vi.hoisted(() => vi.fn())
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({ showDialog, closeDialog })
}))
import { useManagerSurveyDialog } from '@/workbench/extensions/manager/composables/useManagerSurveyDialog'
describe('useManagerSurveyDialog', () => {
beforeEach(() => {
showDialog.mockReset()
closeDialog.mockReset()
})
it('show() opens the survey dialog under its own key via the Reka layout renderer', () => {
useManagerSurveyDialog().show()
const [args] = showDialog.mock.calls[0]
expect(args.key).toBe('global-manager-survey')
expect(args.dialogComponentProps.renderer).toBe('reka')
})
it('show() wires onClose to close the survey dialog', () => {
useManagerSurveyDialog().show()
const [args] = showDialog.mock.calls[0]
args.props.onClose()
expect(closeDialog).toHaveBeenCalledWith({ key: 'global-manager-survey' })
})
it('hide() closes the global-manager-survey dialog', () => {
useManagerSurveyDialog().hide()
expect(closeDialog).toHaveBeenCalledWith({ key: 'global-manager-survey' })
})
})

View File

@@ -0,0 +1,32 @@
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import ManagerSurveyDialog from '@/workbench/extensions/manager/components/survey/ManagerSurveyDialog.vue'
const DIALOG_KEY = 'global-manager-survey'
export function useManagerSurveyDialog() {
const dialogService = useDialogService()
const dialogStore = useDialogStore()
function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
function show() {
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: ManagerSurveyDialog,
props: {
onClose: hide
},
dialogComponentProps: {
contentClass: 'w-full sm:max-w-lg rounded-2xl overflow-hidden'
}
})
}
return {
show,
hide
}
}