Skip to content
8 changes: 6 additions & 2 deletions ui/src/hooks/useMoqYamlSync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,12 @@ const moqYaml = (broadcast: string, gateway = '/moq/test') =>
const nonMoqYaml = 'nodes:\n colorbars:\n kind: video::colorbars\n';

describe('useMoqYamlSync', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});

it('re-derives MoQ settings on a debounce after a direct YAML edit', () => {
const actions = makeActions();
Expand Down
104 changes: 104 additions & 0 deletions ui/src/views/StreamView.loadSamples.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// SPDX-FileCopyrightText: Β© 2025 StreamKit Contributors
//
// SPDX-License-Identifier: MPL-2.0

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import type { useStreamViewState } from '@/hooks/useStreamViewState';
import { useStreamStore } from '@/stores/streamStore';
import type { SamplePipeline } from '@/types/generated/api-types';

import { loadAndApplySamples } from './streamSamples';

// streamSamples imports the stream store, which pulls in the WebTransport-backed
// @moq/* libraries; stub them so the module loads in jsdom.
vi.mock('@moq/hang', () => ({ default: {} }));
vi.mock('@moq/watch', () => ({ default: {}, Broadcast: vi.fn() }));
vi.mock('@moq/publish', () => ({ default: {}, Broadcast: vi.fn() }));
vi.mock('@moq/signals', () => ({ Effect: vi.fn() }));

const listDynamicSamples = vi.fn();
vi.mock('@/services/samples', () => ({
listDynamicSamples: () => listDynamicSamples(),
}));

const sample = (id: string): SamplePipeline =>
({ id, name: id, yaml: `client:\n gateway_path: /moq/${id}\n` }) as SamplePipeline;

function makeViewState(): ReturnType<typeof useStreamViewState> {
return {
selectedTemplateId: '',
setSamples: vi.fn(),
setSamplesLoading: vi.fn(),
setSamplesError: vi.fn(),
setSelectedTemplateId: vi.fn(),
setPipelineYaml: vi.fn(),
} as unknown as ReturnType<typeof useStreamViewState>;
}

describe('loadAndApplySamples (cold-load ordering)', () => {
beforeEach(() => {
listDynamicSamples.mockReset();
});

afterEach(() => {
useStreamStore.setState({ configLoaded: false });
});

it('defers the first derive until config finishes loading', async () => {
listDynamicSamples.mockResolvedValue([sample('a')]);
const viewState = makeViewState();
const deriveMoqFromYaml = vi.fn();

let resolveConfig!: () => void;
const loadConfig = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveConfig = () => resolve();
})
);
useStreamStore.setState({ configLoaded: false, loadConfig });

const done = loadAndApplySamples(viewState, deriveMoqFromYaml);

// The sample list resolves first, but nothing is applied until config
// loads, so the derive never resolves against an empty configServerUrl (#604).
await Promise.resolve();
expect(loadConfig).toHaveBeenCalledTimes(1);
expect(viewState.setPipelineYaml).not.toHaveBeenCalled();
expect(deriveMoqFromYaml).not.toHaveBeenCalled();

resolveConfig();
await done;
expect(viewState.setPipelineYaml).toHaveBeenCalledWith(sample('a').yaml);
expect(deriveMoqFromYaml).toHaveBeenCalledWith(sample('a').yaml);
});

it('does not reload config when it is already loaded (warm load)', async () => {
listDynamicSamples.mockResolvedValue([sample('a')]);
const viewState = makeViewState();
const deriveMoqFromYaml = vi.fn();

const loadConfig = vi.fn(() => Promise.resolve());
useStreamStore.setState({ configLoaded: true, loadConfig });

await loadAndApplySamples(viewState, deriveMoqFromYaml);

expect(loadConfig).not.toHaveBeenCalled();
expect(deriveMoqFromYaml).toHaveBeenCalledWith(sample('a').yaml);
});

it('reports an error and stops loading when the sample fetch fails', async () => {
listDynamicSamples.mockRejectedValue(new Error('boom'));
const viewState = makeViewState();
const deriveMoqFromYaml = vi.fn();

useStreamStore.setState({ configLoaded: true, loadConfig: vi.fn() });

await loadAndApplySamples(viewState, deriveMoqFromYaml);

expect(viewState.setSamplesError).toHaveBeenCalledWith('boom');
expect(viewState.setSamplesLoading).toHaveBeenLastCalledWith(false);
expect(deriveMoqFromYaml).not.toHaveBeenCalled();
});
});
37 changes: 1 addition & 36 deletions ui/src/views/StreamView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,12 @@ import { useStreamViewState } from '@/hooks/useStreamViewState';
import { useVideoCanvas } from '@/hooks/useVideoCanvas';
import { useWebSocket } from '@/hooks/useWebSocket';
import { getApiUrl } from '@/services/base';
import { listDynamicSamples } from '@/services/samples';
import { createSession, listSessions } from '@/services/sessions';
import { useSchemaStore, ensureSchemasLoaded } from '@/stores/schemaStore';
import { useSessionStore } from '@/stores/sessionStore';
import type { Event } from '@/types/types';
import { getLogger } from '@/utils/logger';
import { orderSamplePipelinesSystemFirst } from '@/utils/samplePipelineOrdering';
import { loadAndApplySamples } from '@/views/streamSamples';

import type {
CameraStatus,
Expand Down Expand Up @@ -80,34 +79,6 @@ function autoConnectIfMoq(
})();
}

/** Load dynamic pipeline samples and auto-select the first one. */
async function loadAndApplySamples(
viewState: ReturnType<typeof useStreamViewState>,
deriveMoqFromYaml: (yaml: string) => void
): Promise<void> {
try {
viewState.setSamplesLoading(true);
viewState.setSamplesError(null);
const samples = await listDynamicSamples();
const orderedSamples = orderSamplePipelinesSystemFirst(samples);
viewState.setSamples(orderedSamples);

if (orderedSamples.length > 0 && !viewState.selectedTemplateId) {
const first = orderedSamples[0];
viewState.setSelectedTemplateId(first.id);
viewState.setPipelineYaml(first.yaml);
deriveMoqFromYaml(first.yaml);
}
} catch (error) {
logger.error('Failed to load dynamic samples:', error);
viewState.setSamplesError(
error instanceof Error ? error.message : 'Failed to load pipeline templates'
);
} finally {
viewState.setSamplesLoading(false);
}
}

/** Build the relay connection status label for the streaming status bar. */
function relayStatusLabel(status: ConnectionStatus, connectingStep: string): string {
if (status === 'connected') return 'Relay: connected';
Expand Down Expand Up @@ -473,7 +444,6 @@ const StreamView: React.FC = () => {
setMsePath,
setActiveSession,
clearActiveSession,
loadConfig,
connect,
disconnect,
toggleMicrophone,
Expand Down Expand Up @@ -524,7 +494,6 @@ const StreamView: React.FC = () => {
setMsePath: s.setMsePath,
setActiveSession: s.setActiveSession,
clearActiveSession: s.clearActiveSession,
loadConfig: s.loadConfig,
connect: s.connect,
disconnect: s.disconnect,
toggleMicrophone: s.toggleMicrophone,
Expand Down Expand Up @@ -584,10 +553,6 @@ const StreamView: React.FC = () => {
};
}, []);

useEffect(() => {
if (!configLoaded) loadConfig();
}, [configLoaded, loadConfig]);

const [mseError, setMseError] = useState<string | null>(null);
const mseUrl = React.useMemo(() => {
if (!activeSessionId || !msePath) return null;
Expand Down
52 changes: 52 additions & 0 deletions ui/src/views/streamSamples.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// SPDX-FileCopyrightText: Β© 2025 StreamKit Contributors
//
// SPDX-License-Identifier: MPL-2.0

import type { useStreamViewState } from '@/hooks/useStreamViewState';
import { listDynamicSamples } from '@/services/samples';
import { useStreamStore } from '@/stores/streamStore';
import { getLogger } from '@/utils/logger';
import { orderSamplePipelinesSystemFirst } from '@/utils/samplePipelineOrdering';

const logger = getLogger('streamSamples');

/**
* Load dynamic pipeline samples and auto-select the first one.
*
* The first derive resolves the MoQ server URL from `configServerUrl`, which
* `loadConfig()` populates asynchronously. Config and samples are fetched in
* parallel (`Promise.all`), so the auto-select applies the template and derives
* its MoQ settings synchronously once config is loaded β€” the URL is present even
* when the sample list resolves first (otherwise the field stays blank on a cold
* load until the user edits the client section, issue #604).
*/
export async function loadAndApplySamples(
viewState: ReturnType<typeof useStreamViewState>,
deriveMoqFromYaml: (yaml: string) => void
): Promise<void> {
const { configLoaded, loadConfig } = useStreamStore.getState();
try {
viewState.setSamplesLoading(true);
viewState.setSamplesError(null);
const [, samples] = await Promise.all([
configLoaded ? Promise.resolve() : loadConfig(),
listDynamicSamples(),
]);
const orderedSamples = orderSamplePipelinesSystemFirst(samples);
viewState.setSamples(orderedSamples);

if (orderedSamples.length > 0 && !viewState.selectedTemplateId) {
const first = orderedSamples[0];
viewState.setSelectedTemplateId(first.id);
viewState.setPipelineYaml(first.yaml);
deriveMoqFromYaml(first.yaml);
Comment on lines +31 to +42

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.

πŸ“ Info: Config failure is silently absorbed by Promise.all β€” by design

Since loadConfig() (ui/src/stores/streamStore.ts:240-264) has an internal try/catch and never rejects, a config fetch failure won't cause the Promise.all at ui/src/views/streamSamples.ts:31-34 to reject. Instead, configLoaded is set to true with an empty configServerUrl and an errorMessage in the store. The deriveMoqFromYaml call at line 42 will then resolve the server URL against an empty configServerUrl, resulting in no URL being set (resolveServerUrl at ui/src/utils/moqPeerSettings.ts:158 returns undefined when configServerUrl is empty). The user sees the store's error message and can manually enter a URL. This is correct behavior but worth understanding β€” a config failure doesn't block sample display.

Open in Devin Review (Staging)

Was this helpful? React with πŸ‘ or πŸ‘Ž to provide feedback.

Debug

Playground

}
} catch (error) {
logger.error('Failed to load dynamic samples:', error);
viewState.setSamplesError(
error instanceof Error ? error.message : 'Failed to load pipeline templates'
);
} finally {
viewState.setSamplesLoading(false);
}
}
Loading