Skip to content

fix(ui): re-derive MoQ settings on direct Stream view YAML edits#599

Merged
streamer45 merged 6 commits into
mainfrom
devin/1781469750-fix-stream-yaml-moq-rederive
Jun 15, 2026
Merged

fix(ui): re-derive MoQ settings on direct Stream view YAML edits#599
streamer45 merged 6 commits into
mainfrom
devin/1781469750-fix-stream-yaml-moq-rederive

Conversation

@staging-devin-ai-integration

@staging-devin-ai-integration staging-devin-ai-integration Bot commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Summary

  • In the Stream view, picking a sample re-derived MoQ broadcast/transport settings from the pipeline, but editing the YAML editor directly only updated the YAML text. The broadcast names from the previously-selected sample lingered in the store, so creating a session after pasting a different (or non-MoQ) pipeline made the post-create auto-connect target a stale/irrelevant broadcast and error (Stream view: editing pipeline YAML directly doesn't re-derive MoQ broadcast settings #550).
  • Keep the connection store in sync with the YAML on direct edits. The new useMoqYamlSync hook re-derives on a debounce, but only when the pipeline's client section actually changes — so editing the rest of the pipeline doesn't stomp broadcast names the user is mid-typing, and a non-MoQ pipeline clears the stale names instead of leaving them.
  • This makes the YAML the single source of truth for MoQ settings regardless of how it's edited (sample pick, initial auto-select, or direct paste/type), rather than a special case wired only into the sample picker.

Review & Validation

  • Stream view: pick the first sample (MoQ), then paste a non-MoQ pipeline (e.g. colorbars → core::sink) into the editor — the Input/Output Broadcast fields should clear; creating a session should not attempt a MoQ auto-connect.
  • Editing a non-client part of a MoQ pipeline's YAML should preserve any manual edits to the broadcast fields (no re-derive when the client section is unchanged).
  • just test-ui / just lint-ui, and the overlay-controls e2e (workaround removed) against a running server.

Notes

  • The overlay-controls.spec.ts test previously cleared #input-broadcast/#output-broadcast by hand to dodge this bug; that workaround is removed and the test now waits for the UI to clear them, so it exercises the real behavior and would catch a regression.

Link to Devin session: https://staging.itsdev.in/sessions/a5f880185b3b47d89d88633a2cfd7fb7
Requested by: @streamer45


Devin Review

Status Commit
🟢 Reviewed 4694a46
Open in Devin Review (Staging)

Selecting a sample re-derived MoQ broadcast/transport settings, but editing
the YAML editor directly only updated the YAML text. Stale broadcast names
from the previously-selected sample persisted in the store, so creating a
session after pasting a different (or non-MoQ) pipeline made the post-create
auto-connect target an irrelevant broadcast and error.

Sync the connection store to the YAML on direct edits via a small hook:
re-derive on a debounce, but only when the pipeline's client section actually
changes, so editing the rest of the pipeline doesn't stomp values mid-typing.

Removes the test-side broadcast-clearing workaround in overlay-controls e2e
now that the UI clears stale names itself.

Fixes #550

Signed-off-by: streamkit-devin <devin@streamkit.dev>
@staging-devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@codecov

codecov Bot commented Jun 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.33962% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.82%. Comparing base (7e7bd26) to head (4694a46).

Files with missing lines Patch % Lines
ui/src/hooks/useMoqYamlSync.ts 94.00% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #599      +/-   ##
==========================================
+ Coverage   84.77%   84.82%   +0.04%     
==========================================
  Files         246      247       +1     
  Lines       73750    73801      +51     
  Branches     2358     2505     +147     
==========================================
+ Hits        62525    62599      +74     
+ Misses      11220    11197      -23     
  Partials        5        5              
Flag Coverage Δ
backend 84.80% <ø> (-0.01%) ⬇️
ui 84.92% <94.33%> (+0.48%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
core 85.67% <ø> (ø)
engine 83.33% <ø> (ø)
api 91.14% <ø> (ø)
nodes 84.45% <ø> (-0.02%) ⬇️
server 84.58% <ø> (ø)
plugin-native 83.89% <ø> (ø)
plugin-wasm 95.41% <ø> (ø)
ui-services 86.29% <94.00%> (+0.12%) ⬆️
ui-components 71.96% <ø> (ø)
Files with missing lines Coverage Δ
ui/src/utils/moqPeerSettings.ts 93.42% <100.00%> (+38.75%) ⬆️
ui/src/hooks/useMoqYamlSync.ts 94.00% <94.00%> (ø)

... and 2 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@staging-devin-ai-integration staging-devin-ai-integration Bot left a comment

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.

Devin Review found 4 potential issues.

Open in Devin Review (Staging)
Debug

Playground

Comment thread ui/src/hooks/useMoqYamlSync.ts Outdated
Comment on lines +45 to +46
await expect(page.locator('#input-broadcast')).toHaveValue('');
await expect(page.locator('#output-broadcast')).toHaveValue('');

@staging-devin-ai-integration staging-devin-ai-integration Bot Jun 14, 2026

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: E2E loadPipelineYaml now relies on debounce-driven broadcast clearing instead of manual fill

The old loadPipelineYaml manually cleared broadcast fields via input.fill(''). The new implementation (e2e/tests/overlay-controls.spec.ts:75-79) instead asserts that #input-broadcast and #output-broadcast become empty, relying on the 300ms debounce in useMoqYamlSync to fire and clear them. Playwright's toHaveValue retries for up to 5s by default, so this is safe. However, this means all overlay-controls tests now implicitly depend on the #550 fix working correctly — if the debounce or MoQ clearing logic regresses, the overlay-controls tests would fail with a less obvious error message ("expected '' but got 'some-broadcast'") rather than the dedicated #550 regression test.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

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.

Right, that's the intent: the assertion-based wait replaces the imperative .fill('') workaround so the test now fails (with a timeout) if re-derivation breaks, instead of masking it. Verified locally against a running server with the workaround removed.

Comment thread ui/src/utils/clientSection.ts Outdated
Comment on lines +36 to +38
export function clientSectionSignature(yamlContent: string): string {
return JSON.stringify(parseClientFromYaml(yamlContent));
}

@staging-devin-ai-integration staging-devin-ai-integration Bot Jun 14, 2026

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: clientSignature uses JSON.stringify which is order-dependent

clientSignature at ui/src/hooks/useMoqYamlSync.ts:19 uses JSON.stringify(client) to produce a comparable signature. JSON.stringify is sensitive to property insertion order — if js-yaml (used in parseClientFromYaml) ever reorders properties between two semantically identical YAML strings, the signatures would differ, causing a redundant (but harmless) re-derivation. In practice, js-yaml preserves YAML key order deterministically, and the cost of an extra derive is negligible, so this is not a real problem — just a property of the implementation worth knowing about.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

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.

Agreed — key-order sensitivity is benign here. A reorder-only edit just triggers one redundant re-derive that applies identical settings (a no-op for the user), so a canonical sort/hash isn't worth the extra complexity.

Comment on lines 646 to 649
useEffect(() => {
loadAndApplySamples(viewState, storeActions);
loadAndApplySamples(viewState, deriveMoqFromYaml);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

@staging-devin-ai-integration staging-devin-ai-integration Bot Jun 14, 2026

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: loadAndApplySamples captures deriveMoqFromYaml in mount-only effect

The useEffect([], ...) at StreamView.tsx:646-649 captures deriveMoqFromYaml from the initial render, and the eslint exhaustive-deps rule is suppressed. This is safe because deriveMoqFromYaml is a useCallback depending on storeActions, which is a useMemo over Zustand setters (guaranteed stable). So the reference never changes and there's no stale closure risk. The same pattern existed before the PR with different parameters.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

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.

Correct — deriveMoqFromYaml is a useCallback over the stable storeActions memo, so the mount-only capture is safe and matches the pre-existing pattern.

…ynchronously

deriveMoqFromYaml runs synchronously on sample select and initial load, but
left any pending debounce timer from a prior direct edit running. Since the
editor doesn't fire onChange for external value updates, that stale timer could
fire afterward and re-derive from the previous YAML, overwriting the freshly
selected sample's MoQ settings. Cancel the timer when deriving synchronously,
and cover the race with hook unit tests.

Signed-off-by: streamkit-devin <devin@streamkit.dev>

@staging-devin-ai-integration staging-devin-ai-integration Bot left a comment

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.

Devin Review found 2 new potential issues.

Open in Devin Review (Staging)
Debug

Playground

Comment thread ui/src/hooks/useMoqYamlSync.ts
Comment thread ui/src/views/StreamView.tsx Outdated
Comment on lines +565 to +568
const { deriveMoqFromYaml, handleYamlChange: handlePipelineYamlChange } = useMoqYamlSync(
storeActions,
viewState.setPipelineYaml
);

@staging-devin-ai-integration staging-devin-ai-integration Bot Jun 14, 2026

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: Three distinct YAML update paths are correctly wired

There are three code paths that update pipeline YAML in StreamView: (1) loadAndApplySamples at ui/src/views/StreamView.tsx:96-97 — direct setPipelineYaml + sync deriveMoqFromYaml, (2) handleTemplateSelect at lines 656-657 — same pattern, (3) editor edits via handlePipelineYamlChange at line 863 — debounced derive. Paths 1 and 2 bypass the debounce intentionally: template selection should apply MoQ settings immediately and update the lastDerivedClientRef, which also cancels any pending debounce timer (line 40). This design prevents a stale debounce from overwriting a freshly-selected template's settings.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

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.

Yes — that's the intended design. Paths 1 and 2 (initial load, template select) derive synchronously and now also cancel any pending edit debounce, so a stale timer can't overwrite a freshly-selected template.

streamkit-devin and others added 3 commits June 14, 2026 20:56
Signed-off-by: streamkit-devin <devin@streamkit.dev>
Close the residual race where creating a session within the 300ms edit
debounce let autoConnectIfMoq read stale broadcasts: flush a synchronous
derive before auto-connect and read serverUrl/broadcasts from the store.

Make the #550 e2e a self-contained regression that drives both the
populate and clear transitions on direct edits instead of depending on
whichever sample auto-selects first.

Parse the client section once per derive (was three js-yaml parses) by
threading the parsed client through applyMoqSettings.

Signed-off-by: streamkit-devin <devin@streamkit.dev>

@staging-devin-ai-integration staging-devin-ai-integration Bot left a comment

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.

Devin Review found 2 new potential issues.

Open in Devin Review (Staging)
Debug

Playground

Comment on lines +61 to +73
const handleYamlChange = useCallback(
(yaml: string) => {
setPipelineYaml(yaml);

if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
const client = parseClientFromYaml(yaml);
if (clientSignature(client) === lastDerivedClientRef.current) return;
applyFromClient(client);
}, MOQ_DERIVE_DEBOUNCE_MS);
},
[setPipelineYaml, applyFromClient]
);

@staging-devin-ai-integration staging-devin-ai-integration Bot Jun 15, 2026

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: CodeMirror onChange re-fire after programmatic setPipelineYaml is safely handled

When handleTemplateSelect calls viewState.setPipelineYaml(template.yaml) followed by deriveMoqFromYaml(template.yaml) (ui/src/views/StreamView.tsx:658-659), the React state update will eventually cause CodeMirror to receive a new value prop, which fires its onChange callback — invoking handlePipelineYamlChange. This starts a 300ms debounce timer. However, deriveMoqFromYaml already ran synchronously, updating lastDerivedClientRef via applyFromClient (ui/src/hooks/useMoqYamlSync.ts:46). When the debounce fires, clientSignature(client) === lastDerivedClientRef.current at line 68 evaluates to true, so the callback no-ops. This is correct behavior, but worth noting for anyone modifying the signature comparison logic — removing or weakening it would cause double-derivation on every template select.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

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.

Correct — that's the intended behavior. The programmatic setPipelineYaml on template select makes CodeMirror eventually fire onChange, but the synchronous deriveMoqFromYaml has already set lastDerivedClientRef, so the debounced callback's signature check no-ops. The signature gate is load-bearing for exactly this; I'll keep the note in mind if it's ever touched.

Comment on lines 61 to 68
function autoConnectIfMoq(
status: ConnectionStatus,
serverUrl: string,
connect: () => Promise<boolean>,
viewState: { setSessionCreationError: (msg: string) => void }
): void {
const hasMoqTransport = Boolean(
useStreamStore.getState().outputBroadcast || useStreamStore.getState().inputBroadcast
);
const { serverUrl, outputBroadcast, inputBroadcast } = useStreamStore.getState();
const hasMoqTransport = Boolean(outputBroadcast || inputBroadcast);
if (status !== 'disconnected' || !serverUrl.trim() || !hasMoqTransport) return;

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: autoConnectIfMoq reads status from React state but serverUrl from Zustand store

After the refactor, autoConnectIfMoq reads serverUrl, outputBroadcast, and inputBroadcast directly from useStreamStore.getState() (line 66), which correctly observes values flushed by the preceding synchronous deriveMoqFromYaml call. However, status is still passed as a parameter from the React closure (ui/src/views/StreamView.tsx:690). This asymmetry is safe because status should still be 'disconnected' at the point handleCreateSession calls autoConnectIfMoq (session creation doesn't change connection status). But if status were also read from the store for consistency, it would eliminate any theoretical stale-closure window. Low risk — flagging for awareness only.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

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.

Intentional asymmetry. serverUrl/broadcasts are MoQ values derived from the YAML, so they're read from the store right after the synchronous flush to close the debounce window. status is connection lifecycle state (owned by the connect/disconnect machinery, not derived from YAML) and is guaranteed 'disconnected' here since session creation doesn't open a connection — so reading it from the closure is correct with no stale window in practice. Left as-is.

The previous unconditional create-time re-derive stomped manual edits to
the broadcast/server-url fields (they write to the store without changing
the YAML). Replace it with flushPendingDerive(), which applies a debounced
edit only when one is actually pending and is otherwise a no-op — closing
the auto-connect race without clobbering manual overrides.

Signed-off-by: streamkit-devin <devin@streamkit.dev>

@staging-devin-ai-integration staging-devin-ai-integration Bot left a comment

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.

Devin Review found 2 new potential issues.

Open in Devin Review (Staging)
Debug

Playground

Comment on lines +44 to +48
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const pendingYamlRef = useRef<string | null>(null);
// `null` is a "never derived" sentinel — it never equals a signature string
// (e.g. "null" for non-MoQ YAML), so the first debounced edit always derives.
const lastDerivedClientRef = useRef<string | null>(null);

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: clientSignature sentinel relies on JS null !== string "null" distinction

The lastDerivedClientRef starts as JS null while clientSignature(null) returns JSON.stringify(null) = "null" (the 4-character string). This means "null" === null is false, so the first debounced edit always triggers a derive — even for non-MoQ YAML. After deriving a null client, the ref holds "null" (string), so subsequent identical non-MoQ edits correctly skip. The inline comment at ui/src/hooks/useMoqYamlSync.ts:46-47 documents this, but the mechanism is subtle enough that a future refactor changing the initial value to "" or "null" would silently break the first-edit guarantee.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

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.

Correct, and the JS-null vs string-"null" distinction is intentional and documented inline. The "always derive once" guarantee is covered by the hook tests, so a refactor that changed the sentinel to ""/"null" would break a test rather than fail silently. Leaving as-is.

Comment on lines +690 to +691
flushPendingDerive();
autoConnectIfMoq(status, connect, viewState);

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: flushPendingDerive + autoConnectIfMoq ordering depends on synchronous store updates

The correctness of the flush-then-read pattern at ui/src/views/StreamView.tsx:690-691 depends on applyMoqSettings writing to the zustand store synchronously. If applyMoqSettings or any of the store actions it calls were ever made async (e.g. via middleware or batching), autoConnectIfMoq's useStreamStore.getState() read at ui/src/views/StreamView.tsx:66 could see stale values. Currently all zustand set() calls in the stream store are synchronous, so this is safe — but the coupling is implicit and worth keeping in mind during future refactoring.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

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.

Agreed — the flush-then-read pattern relies on the store actions being synchronous, which all the stream store set() calls are. The hook unit tests pin the synchronous-apply behavior, so a future move to async store updates would surface there too. Leaving as-is.

@streamer45 streamer45 merged commit a834650 into main Jun 15, 2026
26 checks passed
@streamer45 streamer45 deleted the devin/1781469750-fix-stream-yaml-moq-rederive branch June 15, 2026 16:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants