diff --git a/browser_tests/assets/remote_widget.json b/browser_tests/assets/remote_widget.json new file mode 100644 index 0000000000..1e102790a7 --- /dev/null +++ b/browser_tests/assets/remote_widget.json @@ -0,0 +1,48 @@ +{ + "last_node_id": 15, + "last_link_id": 10, + "nodes": [ + { + "id": 15, + "type": "DevToolsRemoteWidgetNode", + "pos": [ + 495, + 735 + ], + "size": [ + 315, + 58 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "STRING", + "type": "STRING", + "links": null + } + ], + "properties": { + "Node name for S&R": "DevToolsRemoteWidgetNode" + }, + "widgets_values": [ + "v1-5-pruned-emaonly-fp16.safetensors" + ] + } + ], + "links": [], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 0.8008869919566275, + "offset": [ + 538.9801226576359, + -55.24554581806672 + ] + } + }, + "version": 0.4 +} \ No newline at end of file diff --git a/browser_tests/remoteWidgets.spec.ts b/browser_tests/remoteWidgets.spec.ts new file mode 100644 index 0000000000..4666497102 --- /dev/null +++ b/browser_tests/remoteWidgets.spec.ts @@ -0,0 +1,258 @@ +import { expect } from '@playwright/test' + +import { ComfyPage, comfyPageFixture as test } from './fixtures/ComfyPage' + +test.describe('Remote COMBO Widget', () => { + const mockOptions = ['d', 'c', 'b', 'a'] + + const addRemoteWidgetNode = async ( + comfyPage: ComfyPage, + nodeName: string, + count: number = 1 + ) => { + const tab = comfyPage.menu.nodeLibraryTab + await tab.open() + await tab.getFolder('DevTools').click() + const nodeEntry = tab.getNode(nodeName).first() + for (let i = 0; i < count; i++) { + await nodeEntry.click() + await comfyPage.nextFrame() + } + } + + const getWidgetOptions = async ( + comfyPage: ComfyPage, + nodeName: string + ): Promise => { + return await comfyPage.page.evaluate((name) => { + const node = window['app'].graph.nodes.find((node) => node.title === name) + return node.widgets[0].options.values + }, nodeName) + } + + const waitForWidgetUpdate = async (comfyPage: ComfyPage) => { + // Force re-render to trigger first access of widget's options + await comfyPage.page.mouse.click(100, 100) + await comfyPage.page.waitForTimeout(256) + } + + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Top') + }) + + test.describe('Loading options', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Top') + await comfyPage.page.route( + '**/api/models/checkpoints**', + async (route, request) => { + const params = new URL(request.url()).searchParams + const sort = params.get('sort') + await route.fulfill({ + body: JSON.stringify(sort ? [...mockOptions].sort() : mockOptions), + status: 200 + }) + } + ) + }) + + test.afterEach(async ({ comfyPage }) => { + await comfyPage.page.unroute('**/api/models/checkpoints**') + }) + + test('lazy loads options when widget is added from node library', async ({ + comfyPage + }) => { + const nodeName = 'Remote Widget Node' + await addRemoteWidgetNode(comfyPage, nodeName) + await waitForWidgetUpdate(comfyPage) + const widgetOptions = await getWidgetOptions(comfyPage, nodeName) + expect(widgetOptions).toEqual(mockOptions) + }) + + test('lazy loads options when widget is added via workflow load', async ({ + comfyPage + }) => { + const nodeName = 'Remote Widget Node' + await comfyPage.loadWorkflow('remote_widget') + await comfyPage.page.waitForTimeout(512) + + const node = await comfyPage.page.evaluate((name) => { + return window['app'].graph.nodes.find((node) => node.title === name) + }, nodeName) + expect(node).toBeDefined() + + await waitForWidgetUpdate(comfyPage) + const widgetOptions = await getWidgetOptions(comfyPage, nodeName) + expect(widgetOptions).toEqual(mockOptions) + }) + + test('applies query parameters from input spec', async ({ comfyPage }) => { + const nodeName = 'Remote Widget Node With Sort Query Param' + await addRemoteWidgetNode(comfyPage, nodeName) + await waitForWidgetUpdate(comfyPage) + const widgetOptions = await getWidgetOptions(comfyPage, nodeName) + expect(widgetOptions).not.toEqual(mockOptions) + expect(widgetOptions).toEqual([...mockOptions].sort()) + }) + + test('handles empty list of options', async ({ comfyPage }) => { + await comfyPage.page.route( + '**/api/models/checkpoints**', + async (route) => { + await route.fulfill({ body: JSON.stringify([]), status: 200 }) + } + ) + + const nodeName = 'Remote Widget Node' + await addRemoteWidgetNode(comfyPage, nodeName) + await waitForWidgetUpdate(comfyPage) + const widgetOptions = await getWidgetOptions(comfyPage, nodeName) + expect(widgetOptions).toEqual([]) + }) + + test('falls back to default value when non-200 response', async ({ + comfyPage + }) => { + await comfyPage.page.route( + '**/api/models/checkpoints**', + async (route) => { + await route.fulfill({ status: 500 }) + } + ) + + const nodeName = 'Remote Widget Node' + await addRemoteWidgetNode(comfyPage, nodeName) + await waitForWidgetUpdate(comfyPage) + const widgetOptions = await getWidgetOptions(comfyPage, nodeName) + + const defaultValue = 'Loading...' + expect(widgetOptions).toEqual(defaultValue) + }) + }) + + test.describe('Lazy Loading Behavior', () => { + test('does not fetch options before widget is added to graph', async ({ + comfyPage + }) => { + let requestWasMade = false + + comfyPage.page.on('request', (request) => { + if (request.url().includes('/api/models/checkpoints')) { + requestWasMade = true + } + }) + + // Wait a reasonable time to ensure no request is made + await comfyPage.page.waitForTimeout(512) + expect(requestWasMade).toBe(false) + }) + + test('fetches options immediately after widget is added to graph', async ({ + comfyPage + }) => { + const requestPromise = comfyPage.page.waitForRequest((request) => + request.url().includes('/api/models/checkpoints') + ) + await addRemoteWidgetNode(comfyPage, 'Remote Widget Node') + const request = await requestPromise + expect(request.url()).toContain('/api/models/checkpoints') + }) + }) + + test.describe('Refresh Behavior', () => { + test('refreshes options when TTL expires', async ({ comfyPage }) => { + // Fulfill each request with a unique timestamp + await comfyPage.page.route( + '**/api/models/checkpoints**', + async (route, request) => { + await route.fulfill({ + body: JSON.stringify([Date.now()]), + status: 200 + }) + } + ) + + const nodeName = 'Remote Widget Node With 300ms Refresh' + await addRemoteWidgetNode(comfyPage, nodeName) + await waitForWidgetUpdate(comfyPage) + const initialOptions = await getWidgetOptions(comfyPage, nodeName) + + // Wait for the refresh (TTL) to expire + await comfyPage.page.waitForTimeout(302) + await comfyPage.page.mouse.click(100, 100) + + const refreshedOptions = await getWidgetOptions(comfyPage, nodeName) + expect(refreshedOptions).not.toEqual(initialOptions) + }) + + test('does not refresh when TTL is not set', async ({ comfyPage }) => { + let requestCount = 0 + await comfyPage.page.route( + '**/api/models/checkpoints**', + async (route) => { + requestCount++ + await route.fulfill({ body: JSON.stringify(['test']), status: 200 }) + } + ) + + const nodeName = 'Remote Widget Node' + await addRemoteWidgetNode(comfyPage, nodeName) + await waitForWidgetUpdate(comfyPage) + + // Force multiple re-renders + for (let i = 0; i < 3; i++) { + await comfyPage.page.mouse.click(100, 100) + await comfyPage.nextFrame() + } + + expect(requestCount).toBe(1) // Should only make initial request + }) + + test('retries failed requests with backoff', async ({ comfyPage }) => { + const timestamps: number[] = [] + await comfyPage.page.route( + '**/api/models/checkpoints**', + async (route) => { + timestamps.push(Date.now()) + await route.fulfill({ status: 500 }) + } + ) + + const nodeName = 'Remote Widget Node' + await addRemoteWidgetNode(comfyPage, nodeName) + + // Wait for a few retries + await comfyPage.page.waitForTimeout(1024) + + // Verify exponential backoff between retries + const intervals = timestamps.slice(1).map((t, i) => t - timestamps[i]) + expect(intervals[1]).toBeGreaterThan(intervals[0]) + }) + }) + + test.describe('Cache Behavior', () => { + test('reuses cached data between widgets with same params', async ({ + comfyPage + }) => { + let requestCount = 0 + await comfyPage.page.route( + '**/api/models/checkpoints**', + async (route) => { + requestCount++ + await route.fulfill({ + body: JSON.stringify(mockOptions), + status: 200 + }) + } + ) + + // Add two widgets with same config + const nodeName = 'Remote Widget Node' + await addRemoteWidgetNode(comfyPage, nodeName, 2) + await waitForWidgetUpdate(comfyPage) + + expect(requestCount).toBe(1) // Should reuse cached data + }) + }) +}) diff --git a/src/hooks/remoteWidgetHook.ts b/src/hooks/remoteWidgetHook.ts new file mode 100644 index 0000000000..0783d0d855 --- /dev/null +++ b/src/hooks/remoteWidgetHook.ts @@ -0,0 +1,145 @@ +import axios from 'axios' + +import { useWidgetStore } from '@/stores/widgetStore' +import type { InputSpec } from '@/types/apiTypes' + +export interface CacheEntry { + data: T[] + timestamp: number + loading: boolean + error: Error | null + fetchPromise?: Promise + controller?: AbortController + lastErrorTime: number + retryCount: number +} + +const dataCache = new Map>() + +const createCacheKey = (inputData: InputSpec): string => { + const { route, query_params = {}, refresh = 0 } = inputData[1] + + const paramsKey = Object.entries(query_params) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => `${k}=${v}`) + .join('&') + + return [route, `r=${refresh}`, paramsKey].join(';') +} + +const getBackoff = (retryCount: number) => { + return Math.min(1000 * Math.pow(2, retryCount), 512) +} + +async function fetchData( + inputData: InputSpec, + controller: AbortController +): Promise { + const { route, response_key, query_params } = inputData[1] + const res = await axios.get(route, { + params: query_params, + signal: controller.signal, + validateStatus: (status) => status === 200 + }) + return response_key ? res.data[response_key] : res.data +} + +export function useRemoteWidget(inputData: InputSpec) { + const { refresh = 0 } = inputData[1] + const isPermanent = refresh <= 0 + const cacheKey = createCacheKey(inputData) + const defaultValue = useWidgetStore().getDefaultValue(inputData) + + const setSuccess = (entry: CacheEntry, data: T[]) => { + entry.retryCount = 0 + entry.lastErrorTime = 0 + entry.error = null + entry.timestamp = Date.now() + entry.data = data ?? defaultValue + } + + const setError = (entry: CacheEntry, error: Error | unknown) => { + entry.retryCount = (entry.retryCount || 0) + 1 + entry.lastErrorTime = Date.now() + entry.error = error instanceof Error ? error : new Error(String(error)) + entry.data ??= defaultValue + } + + const isInitialized = () => { + const entry = dataCache.get(cacheKey) + return entry?.data && entry.timestamp > 0 + } + + const isStale = () => { + const entry = dataCache.get(cacheKey) + return entry?.timestamp && Date.now() - entry.timestamp >= refresh + } + + const isFetching = () => { + const entry = dataCache.get(cacheKey) + return entry?.fetchPromise + } + + const isBackingOff = () => { + const entry = dataCache.get(cacheKey) + return ( + entry?.error && + entry.lastErrorTime && + Date.now() - entry.lastErrorTime < getBackoff(entry.retryCount) + ) + } + + const fetchOptions = async () => { + const entry = dataCache.get(cacheKey) + + const isValid = isInitialized() && (isPermanent || !isStale()) + if (isValid || isBackingOff()) return entry!.data + if (isFetching()) return entry!.fetchPromise + + const currentEntry: CacheEntry = entry || { + data: defaultValue, + timestamp: 0, + loading: false, + error: null, + fetchPromise: undefined, + controller: undefined, + retryCount: 0, + lastErrorTime: 0 + } + dataCache.set(cacheKey, currentEntry) + + try { + currentEntry.loading = true + currentEntry.error = null + currentEntry.controller = new AbortController() + + currentEntry.fetchPromise = fetchData( + inputData, + currentEntry.controller + ) + const data = await currentEntry.fetchPromise + + setSuccess(currentEntry, data) + return currentEntry.data + } catch (err) { + setError(currentEntry, err) + return currentEntry.data + } finally { + currentEntry.loading = false + currentEntry.fetchPromise = undefined + currentEntry.controller = undefined + } + } + + return { + getCacheKey: () => cacheKey, + getCacheEntry: () => dataCache.get(cacheKey), + forceUpdate: () => { + const entry = dataCache.get(cacheKey) + if (entry?.fetchPromise) entry.controller?.abort() // Abort in-flight request + dataCache.delete(cacheKey) + }, + fetchOptions, + defaultValue + } +} diff --git a/src/scripts/widgets.ts b/src/scripts/widgets.ts index df7965d2a5..fd0583db31 100644 --- a/src/scripts/widgets.ts +++ b/src/scripts/widgets.ts @@ -10,8 +10,10 @@ import TiptapTableRow from '@tiptap/extension-table-row' import TiptapStarterKit from '@tiptap/starter-kit' import { Markdown as TiptapMarkdown } from 'tiptap-markdown' +import { useRemoteWidget } from '@/hooks/remoteWidgetHook' import { useSettingStore } from '@/stores/settingStore' import { useToastStore } from '@/stores/toastStore' +import { useWidgetStore } from '@/stores/widgetStore' import { InputSpec } from '@/types/apiTypes' import { api } from './api' @@ -558,16 +560,47 @@ export const ComfyWidgets: Record = { return res }, COMBO(node, inputName, inputData: InputSpec) { - const type = inputData[0] - let defaultValue = type[0] - if (inputData[1] && inputData[1].default) { - defaultValue = inputData[1].default - } + const widgetStore = useWidgetStore() + + const { type, options } = inputData[1] + const defaultValue = widgetStore.getDefaultValue(inputData) + const res = { widget: node.addWidget('combo', inputName, defaultValue, () => {}, { - values: type + values: options ?? inputData[0] }) } + + if (type === 'remote') { + const remoteWidget = useRemoteWidget(inputData) + + const origOptions = res.widget.options + res.widget.options = new Proxy( + origOptions as Record, + { + get(target, prop: string | symbol) { + if (prop !== 'values') return target[prop] + + remoteWidget.fetchOptions().then((options) => { + if (!options || !options.length) return + + const isUninitialized = + res.widget.value === remoteWidget.defaultValue && + !res.widget.options.values?.includes(remoteWidget.defaultValue) + if (isUninitialized) { + res.widget.value = options[0] + res.widget.callback?.(options[0]) + node.graph?.setDirtyCanvas(true) + } + }) + + const current = remoteWidget.getCacheEntry() + return current?.data || widgetStore.getDefaultValue(inputData) + } + } + ) + } + if (inputData[1]?.control_after_generate) { // TODO make combo handle a widget node type? res.widget.linkedWidgets = addValueControlWidgets( diff --git a/src/stores/widgetStore.ts b/src/stores/widgetStore.ts index c5bdd47c20..3bb1cbace9 100644 --- a/src/stores/widgetStore.ts +++ b/src/stores/widgetStore.ts @@ -2,6 +2,11 @@ import { defineStore } from 'pinia' import { computed, ref } from 'vue' import { ComfyWidgetConstructor, ComfyWidgets } from '@/scripts/widgets' +import { + ComboInputSpecV2, + InputSpec, + isComboInputSpecV1 +} from '@/types/apiTypes' import type { BaseInputSpec } from './nodeDefStore' @@ -38,10 +43,37 @@ export const useWidgetStore = defineStore('widget', () => { } } + function getDefaultValue(inputData: InputSpec) { + if (Array.isArray(inputData[0])) + return getDefaultValue(transformComboInput(inputData)) + + const widgetType = getWidgetType(inputData[0], inputData[1].name) + + const [_, props] = inputData + if (props.default) return props.default + + if (widgetType === 'COMBO' && props.options?.length) return props.options[0] + if (props.type === 'remote') return 'Loading...' + return undefined + } + + const transformComboInput = (inputData: InputSpec): ComboInputSpecV2 => { + return isComboInputSpecV1(inputData) + ? [ + 'COMBO', + { + options: inputData[0], + ...Object(inputData[1]) + } + ] + : inputData + } + return { widgets, getWidgetType, inputIsWidget, - registerCustomWidgets + registerCustomWidgets, + getDefaultValue } }) diff --git a/src/types/apiTypes.ts b/src/types/apiTypes.ts index b29c6a88c1..63cd6211d1 100644 --- a/src/types/apiTypes.ts +++ b/src/types/apiTypes.ts @@ -329,18 +329,32 @@ const zStringInputSpec = inputSpec([ }) ]) +const zComboInputProps = zBaseInputSpecValue.extend({ + control_after_generate: z.boolean().optional(), + image_upload: z.boolean().optional(), + type: z.enum(['remote']).optional(), + route: z.string().url().or(z.string().startsWith('/')).optional(), + refresh: z.number().gte(128).safe().or(z.number().lte(0).safe()).optional(), + response_key: z.string().optional(), + query_params: z.record(z.string(), z.string()).optional() +}) + // Dropdown Selection. const zComboInputSpec = inputSpec( - [ - z.array(z.any()), - zBaseInputSpecValue.extend({ - control_after_generate: z.boolean().optional(), - image_upload: z.boolean().optional() - }) - ], + [z.array(z.any()), zComboInputProps], /* allowUpcast=*/ false ) +const zComboInputSpecV2 = inputSpec( + [z.literal('COMBO'), zComboInputProps], + /* allowUpcast=*/ false +) +export function isComboInputSpecV1( + inputSpec: InputSpec +): inputSpec is ComboInputSpec { + return Array.isArray(inputSpec[0]) +} + const excludedLiterals = new Set(['INT', 'FLOAT', 'BOOLEAN', 'STRING', 'COMBO']) const zCustomInputSpec = inputSpec([ @@ -354,6 +368,7 @@ const zInputSpec = z.union([ zBooleanInputSpec, zStringInputSpec, zComboInputSpec, + zComboInputSpecV2, zCustomInputSpec ]) @@ -388,6 +403,8 @@ const zComfyNodeDef = z.object({ }) // `/object_info` +export type ComboInputSpec = z.infer +export type ComboInputSpecV2 = z.infer export type InputSpec = z.infer export type ComfyInputsSpec = z.infer export type ComfyOutputTypesSpec = z.infer diff --git a/tests-ui/tests/remoteWidgetHook.test.ts b/tests-ui/tests/remoteWidgetHook.test.ts new file mode 100644 index 0000000000..b399a291ab --- /dev/null +++ b/tests-ui/tests/remoteWidgetHook.test.ts @@ -0,0 +1,496 @@ +import axios from 'axios' + +import { useRemoteWidget } from '@/hooks/remoteWidgetHook' +import type { ComboInputSpecV2 } from '@/types/apiTypes' + +jest.mock('axios', () => ({ + get: jest.fn() +})) + +jest.mock('@/i18n', () => ({ + i18n: { + global: { + t: jest.fn((key) => key) + } + } +})) + +jest.mock('@/stores/settingStore', () => ({ + useSettingStore: () => ({ + settings: {} + }) +})) + +jest.mock('@/stores/widgetStore', () => ({ + useWidgetStore: () => ({ + widgets: {}, + getDefaultValue: jest.fn().mockReturnValue('Loading...') + }) +})) + +const FIRST_BACKOFF = 1000 // backoff is 1s on first retry + +function createMockInputData(overrides = {}): ComboInputSpecV2 { + return [ + 'COMBO', + { + name: 'test_widget', + type: 'remote', + route: `/api/test/${Date.now()}${Math.random().toString(36).substring(2, 15)}`, + refresh: 0, + ...overrides + } + ] +} + +function mockAxiosResponse(data: unknown, status = 200) { + jest.mocked(axios.get).mockResolvedValueOnce({ data, status }) +} + +function mockAxiosError(error: Error | string) { + const err = error instanceof Error ? error : new Error(error) + jest.mocked(axios.get).mockRejectedValueOnce(err) +} + +function createHookWithData(data: unknown, inputOverrides = {}) { + mockAxiosResponse(data) + const hook = useRemoteWidget(createMockInputData(inputOverrides)) + return hook +} + +async function setupHookWithResponse(data: unknown, inputOverrides = {}) { + const hook = createHookWithData(data, inputOverrides) + const result = await hook.fetchOptions() + return { hook, result } +} + +describe('useRemoteWidget', () => { + let mockInputData: ComboInputSpecV2 + + beforeEach(() => { + jest.clearAllMocks() + // Reset mocks + jest.mocked(axios.get).mockReset() + // Reset cache between tests + jest.spyOn(Map.prototype, 'get').mockClear() + jest.spyOn(Map.prototype, 'set').mockClear() + jest.spyOn(Map.prototype, 'delete').mockClear() + + mockInputData = createMockInputData() + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + describe('initialization', () => { + it('should create hook with default values', () => { + const hook = useRemoteWidget(mockInputData) + expect(hook.getCacheEntry()).toBeUndefined() + expect(hook.defaultValue).toBe('Loading...') + }) + + it('should generate consistent cache keys', () => { + const hook1 = useRemoteWidget(mockInputData) + const hook2 = useRemoteWidget(mockInputData) + expect(hook1.getCacheKey()).toBe(hook2.getCacheKey()) + }) + + it('should handle query params in cache key', () => { + const hook1 = useRemoteWidget( + createMockInputData({ query_params: { a: 1 } }) + ) + const hook2 = useRemoteWidget( + createMockInputData({ query_params: { a: 2 } }) + ) + expect(hook1.getCacheKey()).not.toBe(hook2.getCacheKey()) + }) + }) + + describe('fetchOptions', () => { + it('should fetch data successfully', async () => { + const mockData = ['optionA', 'optionB'] + const { hook, result } = await setupHookWithResponse(mockData) + expect(result).toEqual(mockData) + expect(jest.mocked(axios.get)).toHaveBeenCalledWith( + hook.getCacheKey().split(';')[0], // Get the route part from cache key + expect.any(Object) + ) + }) + + it('should use response_key if provided', async () => { + const mockResponse = { items: ['optionB', 'optionA', 'optionC'] } + const { result } = await setupHookWithResponse(mockResponse, { + response_key: 'items' + }) + expect(result).toEqual(mockResponse.items) + }) + + it('should cache successful responses', async () => { + const mockData = ['optionA', 'optionB', 'optionC', 'optionD'] + const { hook } = await setupHookWithResponse(mockData) + const entry = hook.getCacheEntry() + + expect(entry?.data).toEqual(mockData) + expect(entry?.error).toBeNull() + }) + + it('should handle fetch errors', async () => { + const error = new Error('Network error') + mockAxiosError(error) + + const hook = useRemoteWidget(mockInputData) + const data = await hook.fetchOptions() + expect(data).toBe('Loading...') + + const entry = hook.getCacheEntry() + expect(entry?.error).toBeTruthy() + expect(entry?.lastErrorTime).toBeDefined() + }) + + it('should handle empty array responses', async () => { + const { result } = await setupHookWithResponse([]) + expect(result).toEqual([]) + }) + + it('should handle malformed response data', async () => { + const hook = useRemoteWidget(mockInputData) + const { defaultValue } = hook + + mockAxiosResponse(null) + const data1 = await hook.fetchOptions() + + mockAxiosResponse(undefined) + const data2 = await hook.fetchOptions() + + expect(data1).toBe(defaultValue) + expect(data2).toBe(defaultValue) + }) + + it('should handle non-200 status codes', async () => { + mockAxiosError('Request failed with status code 404') + + const hook = useRemoteWidget(mockInputData) + const data = await hook.fetchOptions() + + expect(data).toBe('Loading...') + const entry = hook.getCacheEntry() + expect(entry?.error?.message).toBe('Request failed with status code 404') + }) + }) + + describe('refresh behavior', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + + afterEach(() => { + jest.useRealTimers() + jest.clearAllMocks() + }) + + describe('permanent widgets (no refresh)', () => { + it('permanent widgets should not attempt fetch after initialization', async () => { + const mockData = ['data that is permanent after initialization'] + const { hook } = await setupHookWithResponse(mockData) + + await hook.fetchOptions() + await hook.fetchOptions() + + expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(1) + }) + + it('permanent widgets should re-fetch if forceUpdate is called', async () => { + const mockData = ['data that is permanent after initialization'] + const { hook } = await setupHookWithResponse(mockData) + + await hook.fetchOptions() + const refreshedData = ['data that user forced to be fetched'] + mockAxiosResponse(refreshedData) + + await hook.forceUpdate() + const data = await hook.fetchOptions() + expect(data).toEqual(refreshedData) + }) + + it('permanent widgets should still retry if request fails', async () => { + mockAxiosError('Network error') + + const hook = useRemoteWidget(mockInputData) + await hook.fetchOptions() + expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(1) + + jest.setSystemTime(Date.now() + FIRST_BACKOFF) + const secondData = await hook.fetchOptions() + expect(secondData).toBe('Loading...') + expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(2) + }) + + it('should treat empty refresh field as permanent', async () => { + const { hook } = await setupHookWithResponse(['data that is permanent']) + + await hook.fetchOptions() + await hook.fetchOptions() + + expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(1) + }) + }) + + it('should refresh when data is stale', async () => { + const refresh = 256 + const mockData1 = ['option1'] + const mockData2 = ['option2'] + + const { hook } = await setupHookWithResponse(mockData1, { refresh }) + mockAxiosResponse(mockData2) + + jest.setSystemTime(Date.now() + refresh) + const newData = await hook.fetchOptions() + + expect(newData).toEqual(mockData2) + expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(2) + }) + + it('should not refresh when data is not stale', async () => { + const { hook } = await setupHookWithResponse(['option1'], { + refresh: 512 + }) + + jest.setSystemTime(Date.now() + 128) + await hook.fetchOptions() + + expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(1) + }) + + it('should use backoff instead of refresh after error', async () => { + const refresh = 4096 + const { hook } = await setupHookWithResponse(['first success'], { + refresh + }) + + mockAxiosError('Network error') + jest.setSystemTime(Date.now() + refresh) + await hook.fetchOptions() + expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(2) + + mockAxiosResponse(['second success']) + jest.setSystemTime(Date.now() + FIRST_BACKOFF) + const thirdData = await hook.fetchOptions() + expect(thirdData).toEqual(['second success']) + expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(3) + }) + + it('should use last valid value after error', async () => { + const refresh = 4096 + const { hook } = await setupHookWithResponse(['a valid value'], { + refresh + }) + + mockAxiosError('Network error') + jest.setSystemTime(Date.now() + refresh) + const secondData = await hook.fetchOptions() + + expect(secondData).toEqual(['a valid value']) + expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(2) + }) + }) + + describe('error handling and backoff', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('should implement exponential backoff on errors', async () => { + mockAxiosError('Network error') + + const hook = useRemoteWidget(mockInputData) + await hook.fetchOptions() + const entry1 = hook.getCacheEntry() + expect(entry1?.error).toBeTruthy() + + await hook.fetchOptions() + expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(1) + + jest.setSystemTime(Date.now() + 500) + await hook.fetchOptions() + expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(1) // Still backing off + + jest.setSystemTime(Date.now() + 3000) + await hook.fetchOptions() + expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(2) + expect(entry1?.data).toBeDefined() + }) + + it('should reset error state on successful fetch', async () => { + mockAxiosError('Network error') + const hook = useRemoteWidget(mockInputData) + const firstData = await hook.fetchOptions() + expect(firstData).toBe('Loading...') + + jest.setSystemTime(Date.now() + 3000) + mockAxiosResponse(['option1']) + const secondData = await hook.fetchOptions() + expect(secondData).toEqual(['option1']) + + const entry = hook.getCacheEntry() + expect(entry?.error).toBeNull() + expect(entry?.retryCount).toBe(0) + }) + + it('should save successful data after backoff', async () => { + mockAxiosError('Network error') + const hook = useRemoteWidget(mockInputData) + await hook.fetchOptions() + const entry1 = hook.getCacheEntry() + expect(entry1?.error).toBeTruthy() + + jest.setSystemTime(Date.now() + 3000) + mockAxiosResponse(['success after backoff']) + const secondData = await hook.fetchOptions() + expect(secondData).toEqual(['success after backoff']) + + const entry2 = hook.getCacheEntry() + expect(entry2?.error).toBeNull() + expect(entry2?.retryCount).toBe(0) + }) + + it('should save successful data after multiple backoffs', async () => { + mockAxiosError('Network error') + mockAxiosError('Network error') + mockAxiosError('Network error') + const hook = useRemoteWidget(mockInputData) + await hook.fetchOptions() + const entry1 = hook.getCacheEntry() + expect(entry1?.error).toBeTruthy() + + jest.setSystemTime(Date.now() + 3000) + const secondData = await hook.fetchOptions() + expect(secondData).toBe('Loading...') + expect(entry1?.error).toBeDefined() + + jest.setSystemTime(Date.now() + 9000) + const thirdData = await hook.fetchOptions() + expect(thirdData).toBe('Loading...') + expect(entry1?.error).toBeDefined() + + jest.setSystemTime(Date.now() + 120_000) + mockAxiosResponse(['success after multiple backoffs']) + const fourthData = await hook.fetchOptions() + expect(fourthData).toEqual(['success after multiple backoffs']) + + const entry2 = hook.getCacheEntry() + expect(entry2?.error).toBeNull() + expect(entry2?.retryCount).toBe(0) + }) + }) + + describe('cache management', () => { + it('should clear cache entries', async () => { + const { hook } = await setupHookWithResponse(['to be cleared']) + expect(hook.getCacheEntry()).toBeDefined() + + hook.forceUpdate() + expect(hook.getCacheEntry()).toBeUndefined() + }) + + it('should prevent duplicate in-flight requests', async () => { + const promise = Promise.resolve({ data: ['non-duplicate'] }) + jest.mocked(axios.get).mockImplementationOnce(() => promise) + + const hook = useRemoteWidget(mockInputData) + const [result1, result2] = await Promise.all([ + hook.fetchOptions(), + hook.fetchOptions() + ]) + + expect(result1).toBe(result2) + expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(1) + }) + }) + + describe('concurrent access and multiple instances', () => { + it('should handle concurrent hook instances with same route', async () => { + mockAxiosResponse(['shared data']) + const hook1 = useRemoteWidget(mockInputData) + const hook2 = useRemoteWidget(mockInputData) + + const [data1, data2] = await Promise.all([ + hook1.fetchOptions(), + hook2.fetchOptions() + ]) + + expect(data1).toEqual(['shared data']) + expect(data2).toEqual(['shared data']) + expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(1) + expect(hook1.getCacheEntry()).toBe(hook2.getCacheEntry()) + }) + + it('should use shared cache across multiple hooks', async () => { + mockAxiosResponse(['shared data']) + const hook1 = useRemoteWidget(mockInputData) + const hook2 = useRemoteWidget(mockInputData) + const hook3 = useRemoteWidget(mockInputData) + const hook4 = useRemoteWidget(mockInputData) + + const data1 = await hook1.fetchOptions() + const data2 = await hook2.fetchOptions() + const data3 = await hook3.fetchOptions() + const data4 = await hook4.fetchOptions() + + expect(data1).toEqual(['shared data']) + expect(data2).toBe(data1) + expect(data3).toBe(data1) + expect(data4).toBe(data1) + expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(1) + expect(hook1.getCacheEntry()).toBe(hook2.getCacheEntry()) + expect(hook2.getCacheEntry()).toBe(hook3.getCacheEntry()) + expect(hook3.getCacheEntry()).toBe(hook4.getCacheEntry()) + }) + + it('should handle rapid cache clearing during fetch', async () => { + let resolvePromise: (value: any) => void + const delayedPromise = new Promise((resolve) => { + resolvePromise = resolve + }) + + jest.mocked(axios.get).mockImplementationOnce(() => delayedPromise) + + const hook = useRemoteWidget(mockInputData) + const fetchPromise = hook.fetchOptions() + hook.forceUpdate() + + resolvePromise!({ data: ['delayed data'] }) + const data = await fetchPromise + + expect(data).toEqual(['delayed data']) + expect(hook.getCacheEntry()).toBeUndefined() + }) + + it('should handle widget destroyed during fetch', async () => { + let resolvePromise: (value: any) => void + const delayedPromise = new Promise((resolve) => { + resolvePromise = resolve + }) + + jest.mocked(axios.get).mockImplementationOnce(() => delayedPromise) + + let hook = useRemoteWidget(mockInputData) + const fetchPromise = hook.fetchOptions() + + hook = null as any + + resolvePromise!({ data: ['delayed data'] }) + await fetchPromise + + expect(hook).toBeNull() + hook = useRemoteWidget(mockInputData) + + const data2 = await hook.fetchOptions() + expect(data2).toEqual(['delayed data']) + }) + }) +})