Skip to content

@moq/watch: add static catalog format#1349

Merged
kixelated merged 5 commits into
moq-dev:mainfrom
skirsten:watch-static-catalog
Apr 29, 2026
Merged

@moq/watch: add static catalog format#1349
kixelated merged 5 commits into
moq-dev:mainfrom
skirsten:watch-static-catalog

Conversation

@skirsten
Copy link
Copy Markdown
Contributor

@skirsten skirsten commented Apr 27, 2026

Summary

  • Adds a "static" catalog format on @moq/watch that lets callers pass a Catalog.Root directly instead of fetching it via the hang or MSF catalog track. Connection + broadcast name are still required so media tracks can be subscribed normally.
  • Promotes Broadcast.catalog from Getter to writable Signal<Catalog.Root | undefined> and accepts an initial catalog prop. <moq-watch> exposes a matching catalog JS property.
  • Adds a demo/web/src/static.html demo with a textarea + Apply button to manually drive <moq-watch catalog-format="static"> against the default bbb broadcast, cross-linked from the existing watch/mse/publish demos.
  • Documents the new format in doc/js/@moq/watch.md.

Test plan

  • bun run check in js/watch passes.
  • bun run build in demo/web produces dist/static.html.
  • just demo running, open static.html, paste a real Catalog.Root (capture from index.html via JSON.stringify(watch.catalog)), click Apply, video plays without a catalog.json track subscription.
  • Default hang and msf flows on index.html still behave as before.

🤖 Generated with Claude Code

skirsten and others added 3 commits April 27, 2026 03:00
Lets callers supply a Catalog.Root directly instead of fetching it via
hang or MSF, while still reusing the connection/broadcast for media
tracks. Broadcast.catalog is now a writable Signal so users can update
it at runtime; <moq-watch> exposes a matching JS property.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A textarea + Apply button to test catalog-format="static" against the
default bbb broadcast. Cross-linked from the existing watch/mse/publish
demos.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Single source of truth so adding a new format is one edit. parseCatalogFormat
becomes a lookup with a default.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 27, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: aae777c1-1f92-4e91-b4a1-2d95dfc9a15e

📥 Commits

Reviewing files that changed from the base of the PR and between 16b2b34 and 2f2e260.

📒 Files selected for processing (1)
  • demo/web/src/manual.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • demo/web/src/manual.ts

Walkthrough

Adds a new "manual" catalog mode to @moq/watch. The CATALOG_FORMATS and CatalogFormat type now include "manual". Broadcast exposes a public writable catalog Signal and accepts an optional catalog input; its catalog run loop skips fetching when catalogFormat is "manual". MoqWatch gains a catalog getter/setter. A manual demo page and module, Vite build entry, documentation updates, and navigation links in demo HTML files were added.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title '@moq/watch: add static catalog format' accurately reflects the main objective of adding a new static catalog format feature to @moq/watch.
Description check ✅ Passed The PR description clearly explains the feature: adds a static catalog format allowing direct Catalog.Root passing, promotes Broadcast.catalog to writable Signal, adds demo page, and documents changes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
✨ Simplify code
  • Create PR with simplified code

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (4)
demo/web/src/static.ts (2)

27-42: Prefer Effect.event() over raw addEventListener.

Per the project guideline, click handling should use Effect.event() from @moq/signals so cleanup is handled automatically. For this demo a top-level Effect (mirroring MoqWatch.signals) would be the simplest fit; it also gives you a place to scope future timers/intervals without leaks if the page wires more controls later.

As per coding guidelines: "Use Effect.interval(), Effect.timer(), and Effect.event() helpers from @moq/signals instead of raw setInterval, setTimeout, addEventListener. These handle cleanup automatically when the Effect is closed."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@demo/web/src/static.ts` around lines 27 - 42, Replace the raw
apply.addEventListener usage with an Effect.event-based handler so the click
listener is created under a top-level Effect and cleaned up automatically;
create or reuse the top-level Effect (e.g., MoqWatch.signals or a new Effect
instance) and call Effect.event(effect, apply, "click", () => { const text =
input.value.trim(); if (!text) { watch.catalog = undefined;
setStatus("cleared"); return; } try { const parsed = JSON.parse(text) as
Catalog.Root; watch.catalogFormat = "static"; watch.catalog = parsed;
setStatus("applied"); } catch (err) { setStatus(`parse error: ${(err as
Error).message}`, false); } }); so the listener is registered via Effect.event
instead of apply.addEventListener and will be automatically disposed when the
Effect closes.

12-14: Make the missing-element diagnostics consistent.

watch gets a friendly throw new Error("missing <moq-watch> element") on line 10, but #catalog-input, #apply, and #status are blind-cast and will fail later with a confusing null deref if any element is renamed/removed. A small null-check (or a tiny byId() helper that throws with the offending id) keeps the demo's failure modes consistent and easier to debug.

♻️ Suggested helper
-const input = document.getElementById("catalog-input") as HTMLTextAreaElement;
-const apply = document.getElementById("apply") as HTMLButtonElement;
-const status = document.getElementById("status") as HTMLSpanElement;
+function byId<T extends HTMLElement>(id: string): T {
+	const el = document.getElementById(id);
+	if (!el) throw new Error(`missing #${id} element`);
+	return el as T;
+}
+
+const input = byId<HTMLTextAreaElement>("catalog-input");
+const apply = byId<HTMLButtonElement>("apply");
+const status = byId<HTMLSpanElement>("status");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@demo/web/src/static.ts` around lines 12 - 14, The code currently blind-casts
DOM lookups (input, apply, status) and will later throw confusing null
dereferences; create a small helper (e.g., byId(id: string): HTMLElement that
queries document.getElementById and throws a clear Error(`missing ${id}
element`) when null) and replace the raw casts with calls like const input =
byId("catalog-input") as HTMLTextAreaElement, const apply = byId("apply") as
HTMLButtonElement, and const status = byId("status") as HTMLSpanElement so
missing elements produce the same friendly diagnostic as the existing watch
check.
js/watch/src/broadcast.ts (2)

99-113: Static branch: status never reaches "offline".

In "static" mode, status only flips between "live" and "loading" based on whether catalog is set. If the broadcast transitioned from "hang"/"msf" to "static", the previous run's finally will already have written "offline", but as soon as this branch runs it'll be reset to "loading" (or "live") regardless of whether the underlying connection/active broadcast exists. If consumers use status === "offline" as a proxy for "no media available", static mode will be inconsistent with the other formats.

Probably acceptable since status is conceptually catalog availability here, but worth a short // why comment so readers don't assume "offline" is reachable in static mode.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/watch/src/broadcast.ts` around lines 99 - 113, The static-branch of
`#runCatalog` currently sets this.status to "live" or "loading" based only on
this.catalog, which prevents "offline" from ever being reached when format ===
"static"; add a short explanatory comment inside the static branch (near the use
of this.catalogFormat / this.catalog and the this.status.set(...) call) that
documents that in "static" mode status represents catalog availability
(live/loading) and that "offline" is intentionally not used here to reflect that
there is no live broadcast connection, so readers won’t mistakenly expect
"offline" to be reachable in static mode.

50-52: Public writable catalog races with the fetch loop in non-static modes.

Promoting catalog to a public writable Signal is what enables both the static path and the <moq-watch>.catalog setter in element.ts (lines 270-276). However, in "hang"/"msf" modes the fetch loop also writes to this same signal at lines 137 and 143, so any external broadcast.catalog.set(...) will be silently overwritten on the next fetch (or wiped to undefined when the spawn unwinds). The doc on BroadcastProps.catalog covers initial values, but the runtime setter doesn't surface the same caveat.

Consider one of:

  • Documenting on the field (line 50-52) that external writes are only stable when catalogFormat === "static", and mirroring that on MoqWatch.catalog in element.ts.
  • Or guarding the setter so it's a no-op (or warns) when catalogFormat !== "static".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/watch/src/broadcast.ts` around lines 50 - 52, The public writable Signal
catalog (BroadcastProps.catalog / catalog in broadcast.ts) races with the
internal fetch loop (which also writes the same signal), so either document that
external writes are only stable when catalogFormat === "static" or prevent
accidental overwrites by guarding the external setter; implement the latter by
making MoqWatch.catalog's setter a no-op or emit a console/process warning when
catalogFormat !== "static" (and similarly enforce/validate in broadcast.ts if
there is a direct public setter), ensuring the fetch loop continues to own
writes in non-static modes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@js/watch/src/broadcast.ts`:
- Around line 140-146: The finally block in broadcast.ts unconditionally calls
this.catalog.set(undefined), which can clobber a user-provided Signal (see
Signal.from behavior); change the logic so the writable catalog is only cleared
when the stream actually errors or ends: remove this.catalog.set(undefined) from
the finally block and instead call this.catalog.set(undefined) inside the catch
(for errors) and at the explicit end-of-stream handling path, leaving planned
Effect teardowns (re-runs) from wiping a consumer's Signal; reference the
this.catalog.set(undefined) call and the surrounding try/catch/finally in
broadcast.ts when making the change.

---

Nitpick comments:
In `@demo/web/src/static.ts`:
- Around line 27-42: Replace the raw apply.addEventListener usage with an
Effect.event-based handler so the click listener is created under a top-level
Effect and cleaned up automatically; create or reuse the top-level Effect (e.g.,
MoqWatch.signals or a new Effect instance) and call Effect.event(effect, apply,
"click", () => { const text = input.value.trim(); if (!text) { watch.catalog =
undefined; setStatus("cleared"); return; } try { const parsed = JSON.parse(text)
as Catalog.Root; watch.catalogFormat = "static"; watch.catalog = parsed;
setStatus("applied"); } catch (err) { setStatus(`parse error: ${(err as
Error).message}`, false); } }); so the listener is registered via Effect.event
instead of apply.addEventListener and will be automatically disposed when the
Effect closes.
- Around line 12-14: The code currently blind-casts DOM lookups (input, apply,
status) and will later throw confusing null dereferences; create a small helper
(e.g., byId(id: string): HTMLElement that queries document.getElementById and
throws a clear Error(`missing ${id} element`) when null) and replace the raw
casts with calls like const input = byId("catalog-input") as
HTMLTextAreaElement, const apply = byId("apply") as HTMLButtonElement, and const
status = byId("status") as HTMLSpanElement so missing elements produce the same
friendly diagnostic as the existing watch check.

In `@js/watch/src/broadcast.ts`:
- Around line 99-113: The static-branch of `#runCatalog` currently sets
this.status to "live" or "loading" based only on this.catalog, which prevents
"offline" from ever being reached when format === "static"; add a short
explanatory comment inside the static branch (near the use of this.catalogFormat
/ this.catalog and the this.status.set(...) call) that documents that in
"static" mode status represents catalog availability (live/loading) and that
"offline" is intentionally not used here to reflect that there is no live
broadcast connection, so readers won’t mistakenly expect "offline" to be
reachable in static mode.
- Around line 50-52: The public writable Signal catalog (BroadcastProps.catalog
/ catalog in broadcast.ts) races with the internal fetch loop (which also writes
the same signal), so either document that external writes are only stable when
catalogFormat === "static" or prevent accidental overwrites by guarding the
external setter; implement the latter by making MoqWatch.catalog's setter a
no-op or emit a console/process warning when catalogFormat !== "static" (and
similarly enforce/validate in broadcast.ts if there is a direct public setter),
ensuring the fetch loop continues to own writes in non-static modes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fe30709d-ec7b-4778-a684-02ef228a6527

📥 Commits

Reviewing files that changed from the base of the PR and between 0331db7 and d7bf380.

📒 Files selected for processing (9)
  • demo/web/src/index.html
  • demo/web/src/mse.html
  • demo/web/src/publish.html
  • demo/web/src/static.html
  • demo/web/src/static.ts
  • demo/web/vite.config.ts
  • doc/js/@moq/watch.md
  • js/watch/src/broadcast.ts
  • js/watch/src/element.ts

Comment thread js/watch/src/broadcast.ts
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
js/watch/src/broadcast.ts (1)

143-146: ⚠️ Potential issue | 🟡 Minor

finally still clobbers a user-supplied catalog Signal on every re-run.

The previously raised concern is unresolved. Because Signal.from(props?.catalog) returns the caller's Signal when one was passed, and this effect re-runs whenever enabled/catalogFormat/active changes, the finally block writes undefined into a signal the consumer may still own and observe. Concretely, switching catalogFormat from "hang"/"msf" to "static" cancels the spawn, triggers finally, and wipes the user's initial catalog before the static branch at lines 106–111 reads it — so a freshly-applied static catalog ends up as undefined/"loading".

Move the clear into the catch and the explicit break (end-of-stream) paths so planned teardowns don't destroy consumer-owned state.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/watch/src/broadcast.ts` around lines 143 - 146, The finally block in the
effect is clobbering consumer-owned Signal instances by always calling
this.catalog.set(undefined) and this.status.set("offline") on every re-run;
instead, remove those clears from the finally and only clear catalog/status in
the error path and in the deliberate termination (the catch block and the
explicit end-of-stream/break handling) so Signal.from(props?.catalog) returns
and retains the caller's Signal when a planned branch switch (e.g., from
hang/msf to static) re-runs; update the code around the effect that uses
this.catalog.set and this.status.set to perform clears only on errors or
explicit end-of-stream, not inside the finally.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@js/watch/src/broadcast.ts`:
- Around line 143-146: The finally block in the effect is clobbering
consumer-owned Signal instances by always calling this.catalog.set(undefined)
and this.status.set("offline") on every re-run; instead, remove those clears
from the finally and only clear catalog/status in the error path and in the
deliberate termination (the catch block and the explicit end-of-stream/break
handling) so Signal.from(props?.catalog) returns and retains the caller's Signal
when a planned branch switch (e.g., from hang/msf to static) re-runs; update the
code around the effect that uses this.catalog.set and this.status.set to perform
clears only on errors or explicit end-of-stream, not inside the finally.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f7bacfa1-8b39-4743-8d0a-3927106db332

📥 Commits

Reviewing files that changed from the base of the PR and between d7bf380 and d99144f.

📒 Files selected for processing (2)
  • js/watch/src/broadcast.ts
  • js/watch/src/element.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • js/watch/src/element.ts

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
js/watch/src/broadcast.ts (1)

100-155: ⚠️ Potential issue | 🟡 Minor

Status can get stuck stale after a planned teardown.

Previously the old finally always reset status to "offline" on every re-run. Now, when the run is aborted (e.g., enabled flips to false, name changes, or catalogFormat switches), the spawn returns early at Line 139/148 without touching status, and the new run bails at Line 102 (when enabled is false) or hits Line 113-114 (when active is undefined) without resetting it either. Net effect: a previously "live" (or "loading") broadcast keeps showing "live" after the user disables it, until something else writes status. The catalog-clobber fix is the right shape; consider decoupling status from catalog so abort-driven teardown still resets status while leaving the user-owned catalog Signal alone.

♻️ Sketch: reset status on abort, keep catalog intact
 		effect.spawn(async () => {
 			try {
 				for (;;) {
 					const update = await Promise.race([effect.cancel, fetchNext()]);
-					if (aborted.aborted) return;
+					if (aborted.aborted) {
+						this.status.set("offline");
+						return;
+					}
 					if (!update) break;

 					console.debug("received catalog", format, this.name.peek(), update);

 					this.catalog.set(update);
 					this.status.set("live");
 				}
 			} catch (err) {
-				if (aborted.aborted) return;
+				if (aborted.aborted) {
+					this.status.set("offline");
+					return;
+				}
 				console.warn("error fetching catalog", this.name.peek(), err);
 			}

 			this.catalog.set(undefined);
 			this.status.set("offline");
 		});

You may also want to set status to "offline" in the early-return branches at Line 102 and Line 114 for the non-static path, so disabling/disconnecting reflects in the UI immediately rather than waiting for the spawn to wind down.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/watch/src/broadcast.ts` around lines 100 - 155, The runCatalog method can
leave this.status stale when a run is aborted or when early-returning (e.g.,
enabled flips false or no active broadcast); update `#runCatalog` to explicitly
reset status to "offline" on those early-return paths (before the returns at the
top when enabled is false and after the check for !broadcast) and also ensure
the spawned task sets this.status to "offline" on abort-return (the branches
that return when aborted.aborted) while deliberately not touching this.catalog
to preserve user-owned catalog Signal; locate changes around the symbols
runCatalog, this.status.set, this.catalog.set, effect.abort/aborted, and
effect.spawn/fetchNext.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@js/watch/src/broadcast.ts`:
- Around line 100-155: The runCatalog method can leave this.status stale when a
run is aborted or when early-returning (e.g., enabled flips false or no active
broadcast); update `#runCatalog` to explicitly reset status to "offline" on those
early-return paths (before the returns at the top when enabled is false and
after the check for !broadcast) and also ensure the spawned task sets
this.status to "offline" on abort-return (the branches that return when
aborted.aborted) while deliberately not touching this.catalog to preserve
user-owned catalog Signal; locate changes around the symbols runCatalog,
this.status.set, this.catalog.set, effect.abort/aborted, and
effect.spawn/fetchNext.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 75aca57b-4516-4ce7-9d73-4b18a78b8246

📥 Commits

Reviewing files that changed from the base of the PR and between d99144f and 5b63b51.

📒 Files selected for processing (1)
  • js/watch/src/broadcast.ts

Copy link
Copy Markdown
Collaborator

@kixelated kixelated left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks good, small nits

Comment thread js/watch/src/broadcast.ts Outdated

// Capture the AbortSignal of *this* run; it flips to aborted on teardown
// (re-run or close) and lets us tell that apart from a natural stream end,
// so we never clobber a user-provided catalog Signal on a planned re-run.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this? If the format is static, we return early and never reach this code.

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.

claude added this because of coderabbits review: Basically when changing format from hang to static and the other way it was not happy. But yea, I also don't like this logic. I will try to simplify it.

Comment thread doc/js/@moq/watch.md Outdated

### Static catalogs

Use `catalog-format="static"` (or `catalogFormat: "static"`) to skip the catalog
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Is static the best name if it can change? Maybe use manual instead?

Per PR review: "static" implies immutable, but the catalog can be reassigned
at any time — "manual" better reflects that the user owns updates. Renames
the format value, the demo files (static.html/ts → manual.html/ts), the
Vite entry, cross-links, and docs.

Also documents that switching catalogFormat between "manual" and a fetched
format tears down the previous fetch loop and clears the catalog signal,
so users should set the catalog after switching to "manual", not before.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@skirsten skirsten force-pushed the watch-static-catalog branch from 5b63b51 to 16b2b34 Compare April 28, 2026 01:11
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
js/watch/src/broadcast.ts (1)

133-149: ⚠️ Potential issue | 🟠 Major

Fetch-loop teardown still clears catalog unconditionally.

finally always calls this.catalog.set(undefined), so effect re-runs/teardowns still wipe catalog state (including externally supplied signal-backed state), not just genuine end/error paths.

#!/bin/bash
# Verify the unconditional clear remains in fetched-mode spawn teardown.
rg -nP --type=ts -C4 'effect\.spawn\(|Promise\.race\(\[effect\.cancel,\s*fetchNext\(\)\]\)|finally\s*{\s*this\.catalog\.set\(undefined\)' js/watch/src/broadcast.ts

# Inspect Effect cancellation contract to distinguish teardown vs stream-end handling.
rg -nP --type=ts -C4 '\bclass\s+Effect\b|\bcancel\b' js/signals/src
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/watch/src/broadcast.ts` around lines 133 - 149, The finally block
unconditionally clears this.catalog (this.catalog.set(undefined)) so effect
re-runs or cancellations wipe externally supplied/catalog state; fix by
detecting cancellation vs genuine end/error: in the effect.spawn async block add
a local flag (e.g., let cancelled = false), set cancelled = true when
Promise.race returns effect.cancel (or when you detect the Effect cancellation
error in the catch), and then in finally only call this.catalog.set(undefined)
and this.status.set("offline") when cancelled === false (or when the stream
actually ended/errored), preserving catalog when the effect was
cancelled/re-run; reference effect.spawn, fetchNext(), the
Promise.race([...effect.cancel, fetchNext()]) branch, the catch block, and the
finally block (this.catalog.set(undefined), this.status.set("offline")) to apply
the conditional clear.
🧹 Nitpick comments (2)
demo/web/src/manual.ts (1)

27-42: Use Effect.event() instead of raw addEventListener for auto-cleanup consistency.

This listener is currently unmanaged and bypasses the project’s effect-lifecycle pattern.

As per coding guidelines, "Use Effect.interval(), Effect.timer(), and Effect.event() helpers from @moq/signals instead of raw setInterval, setTimeout, addEventListener."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@demo/web/src/manual.ts` around lines 27 - 42, Replace the unmanaged
apply.addEventListener call with the project lifecycle helper Effect.event so
the listener is tracked and auto-cleaned; locate the click handler where
apply.addEventListener(...) is used (and references input.value,
watch.catalog/watch.catalogFormat, and setStatus) and register it via
Effect.event(...) from `@moq/signals`, moving the same logic (trim, parse,
try/catch, setting watch.* and setStatus) into the Effect.event callback, then
remove the raw addEventListener to ensure consistent effect lifecycle
management.
demo/web/src/manual.html (1)

40-52: Add a proper label and live status semantics for the catalog form.

textarea#catalog-input has no explicit label, and #status updates are not announced to assistive tech. This makes parse/apply feedback harder to use non-visually.

♿ Suggested markup tweak
-		<h3>Catalog JSON</h3>
+		<h3><label for="catalog-input">Catalog JSON</label></h3>
@@
-			<span id="status" class="ml-2 opacity-80 text-sm"></span>
+			<span id="status" role="status" aria-live="polite" class="ml-2 opacity-80 text-sm"></span>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@demo/web/src/manual.html` around lines 40 - 52, The catalog textarea lacks an
accessible label and the status span is not announced to assistive tech; add a
visible or visually-hidden <label> associated with textarea#catalog-input (match
the id and reference it from the label) so screen readers announce the field,
and make span#status (or replace it) use live region semantics (e.g., add
aria-live="polite" and aria-atomic="true" and/or role="status") so updates after
clicking button#apply are announced; ensure the label text clearly describes the
expected JSON/catalog input and keep button#apply behavior unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@demo/web/src/manual.ts`:
- Line 3: The code currently uses a type-only import for Catalog and assigns
parsed JSON directly to watch.catalog without schema validation; change the
import to a runtime import (remove "type") so Catalog.RootSchema is available at
runtime, then parse the JSON and run Catalog.RootSchema.safeParse(parsed) and
only assign watch.catalog when safeParse succeeds (handle or surface validation
errors otherwise); update the code paths that set watch.catalog to perform this
validation using Catalog.RootSchema.safeParse().

---

Duplicate comments:
In `@js/watch/src/broadcast.ts`:
- Around line 133-149: The finally block unconditionally clears this.catalog
(this.catalog.set(undefined)) so effect re-runs or cancellations wipe externally
supplied/catalog state; fix by detecting cancellation vs genuine end/error: in
the effect.spawn async block add a local flag (e.g., let cancelled = false), set
cancelled = true when Promise.race returns effect.cancel (or when you detect the
Effect cancellation error in the catch), and then in finally only call
this.catalog.set(undefined) and this.status.set("offline") when cancelled ===
false (or when the stream actually ended/errored), preserving catalog when the
effect was cancelled/re-run; reference effect.spawn, fetchNext(), the
Promise.race([...effect.cancel, fetchNext()]) branch, the catch block, and the
finally block (this.catalog.set(undefined), this.status.set("offline")) to apply
the conditional clear.

---

Nitpick comments:
In `@demo/web/src/manual.html`:
- Around line 40-52: The catalog textarea lacks an accessible label and the
status span is not announced to assistive tech; add a visible or visually-hidden
<label> associated with textarea#catalog-input (match the id and reference it
from the label) so screen readers announce the field, and make span#status (or
replace it) use live region semantics (e.g., add aria-live="polite" and
aria-atomic="true" and/or role="status") so updates after clicking button#apply
are announced; ensure the label text clearly describes the expected JSON/catalog
input and keep button#apply behavior unchanged.

In `@demo/web/src/manual.ts`:
- Around line 27-42: Replace the unmanaged apply.addEventListener call with the
project lifecycle helper Effect.event so the listener is tracked and
auto-cleaned; locate the click handler where apply.addEventListener(...) is used
(and references input.value, watch.catalog/watch.catalogFormat, and setStatus)
and register it via Effect.event(...) from `@moq/signals`, moving the same logic
(trim, parse, try/catch, setting watch.* and setStatus) into the Effect.event
callback, then remove the raw addEventListener to ensure consistent effect
lifecycle management.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 34440543-682b-4394-ab4a-4c98554978d1

📥 Commits

Reviewing files that changed from the base of the PR and between 5b63b51 and 16b2b34.

📒 Files selected for processing (9)
  • demo/web/src/index.html
  • demo/web/src/manual.html
  • demo/web/src/manual.ts
  • demo/web/src/mse.html
  • demo/web/src/publish.html
  • demo/web/vite.config.ts
  • doc/js/@moq/watch.md
  • js/watch/src/broadcast.ts
  • js/watch/src/element.ts
✅ Files skipped from review due to trivial changes (4)
  • demo/web/src/publish.html
  • demo/web/src/mse.html
  • demo/web/vite.config.ts
  • demo/web/src/index.html
🚧 Files skipped from review as they are similar to previous changes (1)
  • js/watch/src/element.ts

Comment thread demo/web/src/manual.ts Outdated
The Apply handler was casting JSON.parse output straight to Catalog.Root,
so malformed input silently produced garbage that broke later subscribe
calls in confusing ways. Switch to a runtime import and use
Catalog.RootSchema.safeParse, surfacing parse vs. schema errors
separately in the status line.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@kixelated kixelated merged commit 5059b1a into moq-dev:main Apr 29, 2026
1 check passed
@kixelated
Copy link
Copy Markdown
Collaborator

thanks @skirsten !

@skirsten skirsten deleted the watch-static-catalog branch April 30, 2026 22:02
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