Skip to content

refactor(app): the host seam + carve section practice out of app.js (R3a)#887

Merged
byrongamatos merged 1 commit into
mainfrom
feat/r3-host-seam
Jul 11, 2026
Merged

refactor(app): the host seam + carve section practice out of app.js (R3a)#887
byrongamatos merged 1 commit into
mainfrom
feat/r3-host-seam

Conversation

@byrongamatos

@byrongamatos byrongamatos commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Stacked on #886 (the <audio> hinge). static/js/host.js (99) + static/js/section-practice.js (1,214). app.js 9,461 → 8,409.

The first slice out of the strongly-connected core

What's left in app.js isn't a tree, it's a cycle. Seeding a dependency closure from section-practice, from loops, from count-in, or from the JUCE seek shim all return the same 178-function set — and setLoop() and practiceSection() call each other directly. No closure-based carve can cut it, at any seed.

So it's cut by name, and the calls back into app.js go through a host seam.

  • 61 functions + its own 24 _sectionPractice* / _sectionParents* scalars (read nowhere else) move out
  • 11 hooks come back in — and 4 are read-only getters. loopA / loopB / _audioSeekGen / _loopMutationGen are only ever read here, never written, so app.js keeps owning them and no state container is needed. That avoids a 977-site lift.

app.js used to reach in and reset the module's state by hand (clearLoop() zeroed the selection; changeArrangement() invalidated the parent count). It can't now — an imported binding is read-only — so those became resetSelection() and invalidateParentCount(). That's strictly better: the module owns its own invariants instead of trusting two callers on the far side of the file to zero the right three fields.

The silent-no-op problem, solved

The obvious host seam is an object of no-op defaults. That is a trap, and we walked into it once: the plugin loader's seam defaulted populateVizPicker to () => {}, so a dropped wiring line would have left the viz picker quietly not refreshing — with no test, boot check, or bot noticing.

Two layers stop it:

1. Runtime. host.js is a Proxy with no defaults and no stubs. Reading an unwired hook throws. An unwired hook cannot degrade into a no-op, because there is nothing to degrade into. configureHost() also rejects a non-function at wire time, and refuses to run twice.

2. Static. tests/js/host_contract.test.js asserts the hooks the modules use are exactly the hooks app.js wires. This is the layer that matters — a runtime throw only fires if the broken path executes, and the whole danger of a seam is the paths that never run in a smoke test.

Verified to bite in all three drift directions:

drift caught
drop a hook from configureHost "read by a module but never wired — they would throw at runtime"
rename host.setLoop in the module
wire a hook nobody uses "dead weight, usually the fossil of a rename"

Writing that guard took three attempts, and each failure is worth knowing:

  • the configureHost regex anchored }); at column 0, ran past the indented close, and swallowed app.js's 66-name window contract — reporting 77 "hooks"
  • an import-stripping regex with [\s\S]*? ate 14,000 characters, including the very drift the bite test was meant to catch. A guard with a hole is worse than no guard, because you trust it.
  • host.js' in the import path backtracked from js to a "hook" called j

The bite tests are what surfaced all three. A guard nobody has tried to break is a guess.

Codex found a real race [P2]

configureHost() was inside the async boot function, after several awaits — but the window handlers (onPhraseNext, …) go live during app.js's synchronous module evaluation. A user clicking one in that window would hit [host] … was read before configureHost() ran.

It's now a bare top-level statement, immediately before the window contract, so the seam is always wired before a handler can be reached. Verified live: invoking a handler 1.2s in — well before the boot awaits settle — works.

Verification

A/B against origin/main in two browsers with a real song loaded: popover toggle, practice-mode change, phrase-next, and clearLoop — all of which cross the seam (setLoop / clearLoop / _audioTime / loopA / loopB). Identical, zero page errors. Since an unwired hook throws, a live app is itself proof the seam is wired.

Harnesses: section_practice_dismiss retargeted; loop_api's clearLoop sandbox gains a resetSelection spy (not a stub) and asserts it fires — the guarantee is still tested, just through the seam.

pytest 2396 · node 1040/1040 · ESLint 0 (no-cycle clean) · tailwind clean · Codex 0.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a dedicated Section Practice experience with collapsible section and phrase controls.
    • Select full sections or individual parts, navigate between parts, and view timing details.
    • Improved playback synchronization, highlighting, count-ins, and retry handling.
    • Added convenient popover dismissal with outside-click and Escape-key support.
  • Bug Fixes

    • Loop clearing now reliably resets Section Practice selections and highlights.

@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Section Practice logic moves from static/app.js into static/js/section-practice.js, using a validated host callback seam. App integration, loop teardown, and related contract and unit tests are updated.

Changes

Section Practice extraction

Layer / File(s) Summary
Host seam and contract validation
static/js/host.js, static/app.js, tests/js/host_contract.test.js
Adds one-time validated host wiring with runtime hook enforcement, connects app callbacks, and checks that wired and consumed hooks match.
Section Practice module implementation
static/js/section-practice.js, tests/js/section_practice_dismiss.test.js
Adds section and phrase range derivation, bar and popover behavior, playback synchronization, guarded retries, interaction callbacks, and module-level dismissal coverage.
App integration and loop teardown
static/app.js, tests/js/loop_api.test.js
Imports the extracted Section Practice behavior, assigns window callbacks, delegates invalidation and selection reset, and verifies loop clearing invokes resetSelection().

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant section_practice_js
  participant host_js
  participant app_js
  User->>section_practice_js: select section or phrase part
  section_practice_js->>host_js: request loop and seek
  host_js->>app_js: invoke playback hooks
  app_js-->>host_js: return playback state
  host_js-->>section_practice_js: complete practice request
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 18.67% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly reflects the main change: introducing a host seam and extracting section-practice logic from app.js.
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.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/r3-host-seam

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
static/js/section-practice.js (1)

1045-1065: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Unconditional final sleep wastes time in the failure path.

On the last retry attempt (attempt === 4), if ok is still false, the loop still awaits setTimeout(res, 60 + attempt * 90) (≈420ms) before exiting, even though no further attempt will use that delay. This adds an unnecessary user-visible stall before practiceSection() falls back to turning off Section Practice mode on total failure.

⏱️ Skip the trailing sleep on the last attempt
         if (ok) break;
-        await new Promise(res => setTimeout(res, 60 + attempt * 90));
+        if (attempt < 4) await new Promise(res => setTimeout(res, 60 + attempt * 90));
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@static/js/section-practice.js` around lines 1045 - 1065, Update the retry
loop around setLoop so it only waits before another attempt; after a failed
attempt, skip the delay when attempt is the final iteration (attempt === 4).
Preserve the existing retry timing and success break behavior for earlier
attempts.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@static/js/section-practice.js`:
- Around line 1045-1065: Update the retry loop around setLoop so it only waits
before another attempt; after a failed attempt, skip the delay when attempt is
the final iteration (attempt === 4). Preserve the existing retry timing and
success break behavior for earlier attempts.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e95ce0ba-8124-402c-a4a7-ebd6d29a9639

📥 Commits

Reviewing files that changed from the base of the PR and between 0c9e7ff and ca16e6f.

📒 Files selected for processing (6)
  • static/app.js
  • static/js/host.js
  • static/js/section-practice.js
  • tests/js/host_contract.test.js
  • tests/js/loop_api.test.js
  • tests/js/section_practice_dismiss.test.js
🚧 Files skipped from review as they are similar to previous changes (2)
  • static/js/host.js
  • static/app.js

…R3a)

static/js/host.js (99) + static/js/section-practice.js (1,214).
app.js 9,461 → 8,409.

THE FIRST SLICE OUT OF THE STRONGLY-CONNECTED CORE. What is left in app.js is not
a tree, it is a cycle: seeding a dependency closure from section-practice, from
loops, from count-in, or from the JUCE seek shim all return the SAME 178-function
set, and setLoop() and practiceSection() call each other directly. No closure-based
carve can cut it at any seed. So it is cut BY NAME, and the calls back into app.js
go through a host seam.

  61 functions + its own 24 _sectionPractice*/_sectionParents* scalars (read nowhere
  else) move out. 11 hooks come back in. 4 of those are read-only GETTERS —
  loopA/loopB/_audioSeekGen/_loopMutationGen are only ever READ here, never written,
  so app.js keeps owning them and NO state container is needed (a 977-site lift
  avoided).

app.js used to reach IN and reset the module's state by hand (clearLoop() zeroed the
selection; changeArrangement() invalidated the parent count). It cannot now — an
imported binding is read-only — so those are exported as resetSelection() and
invalidateParentCount(). Strictly better: the module owns its own invariants instead
of trusting two callers on the far side of the file to zero the right three fields.

═══ THE SILENT-NO-OP PROBLEM, SOLVED ═══
The obvious host seam is an object of no-op defaults. That is a TRAP and we walked
into it once: the plugin loader's seam defaulted populateVizPicker to `() => {}`, so
a dropped wiring line would have left the viz picker quietly not refreshing with NO
test, boot check, or bot noticing. Two layers stop it here:

  1. RUNTIME — host.js is a Proxy with NO defaults and NO stubs. Reading an unwired
     hook THROWS. An unwired hook cannot degrade into a no-op because there is
     nothing to degrade INTO. configureHost() also rejects a non-function at WIRE
     time, and refuses to run twice.

  2. STATIC — tests/js/host_contract.test.js asserts the hooks the modules USE are
     exactly the hooks app.js WIRES. This is the layer that matters: a runtime throw
     only fires if the broken path executes, and the whole danger of a seam is the
     paths that never run in a smoke test. VERIFIED TO BITE in all three drift
     directions: drop a hook from configureHost -> fails; rename host.setLoop in the
     module -> fails; wire a hook nobody uses -> fails.

Writing that guard took three tries and each failure is instructive: (a) the
configureHost regex anchored `});` at column 0, ran past the indented close, and
swallowed app.js's 66-name window contract — 77 "hooks"; (b) an import-stripping
regex with `[\s\S]*?` ate 14,000 characters INCLUDING the drift the bite test was
meant to catch — a guard with a hole is worse than no guard, because you trust it;
(c) `host.js'` in the import path backtracked from `js` to a "hook" called `j`.
The bite tests are what surfaced all three.

CODEX FOUND A REAL RACE [P2]. configureHost() was inside the async boot function,
after several awaits — but the window handlers (onPhraseNext, …) go live during
app.js's SYNCHRONOUS module evaluation. A user clicking one in that window would hit
"[host] … was read before configureHost() ran". It is now a bare top-level statement
sitting immediately before the window contract, so the seam is always wired before a
handler can be reached. Verified live: invoking a handler 1.2s in — well before the
boot awaits settle — works.

VERIFIED. A/B against origin/main in two browsers with a REAL song loaded: popover
toggle, practice-mode change, phrase-next, and clearLoop (all of which cross the seam
— setLoop/clearLoop/_audioTime/loopA/loopB) — IDENTICAL, zero page errors. Since an
unwired hook throws, a live app is itself proof the seam is wired.

Harnesses: section_practice_dismiss retargeted; loop_api's clearLoop sandbox gains a
resetSelection SPY (not a stub) and ASSERTS it fires — the guarantee is still tested,
just through the seam.

pytest 2396, node 1040/1040, ESLint 0 (no-cycle clean), tailwind clean, Codex 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
static/js/section-practice.js (1)

344-350: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consolidate the three duplicated selection-reset implementations.

_sectionPracticeResetSelectionUi(), the tail of _hideSectionPracticeBar(), and the exported resetSelection() each re-zero the same selection scalars independently. The module's own header comment argues the point of owning this state is that a caller shouldn't have to "zero the right fields" by hand — the same risk now exists internally across three call sites that can drift from each other.

♻️ Proposed consolidation
+function _resetSectionPracticeSelectionScalars() {
+    _sectionPracticeSelected = -1;
+    _sectionPracticeWholeSection = false;
+    _sectionPracticeSavedPartIndex = 0;
+}
+
 function _sectionPracticeResetSelectionUi() {
     _sectionPracticeActiveParent = -1;
-    _sectionPracticeSelected = -1;
-    _sectionPracticeWholeSection = false;
-    _sectionPracticeSavedPartIndex = 0;
+    _resetSectionPracticeSelectionUi();
     _sectionPracticeRanges = [];
 }
 
 export function resetSelection() {
-    _sectionPracticeSelected = -1;
-    _sectionPracticeWholeSection = false;
-    _sectionPracticeSavedPartIndex = 0;
+    _resetSectionPracticeSelectionUi();
 }

Also applies to: 790-814, 1201-1206

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@static/js/section-practice.js` around lines 344 - 350, Consolidate
selection-state resets through _sectionPracticeResetSelectionUi: update the tail
of _hideSectionPracticeBar() and the exported resetSelection() to call this
helper instead of independently assigning the selection scalars. Keep the helper
as the single owner of resetting _sectionPracticeActiveParent,
_sectionPracticeSelected, _sectionPracticeWholeSection,
_sectionPracticeSavedPartIndex, and _sectionPracticeRanges.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@static/js/section-practice.js`:
- Around line 344-350: Consolidate selection-state resets through
_sectionPracticeResetSelectionUi: update the tail of _hideSectionPracticeBar()
and the exported resetSelection() to call this helper instead of independently
assigning the selection scalars. Keep the helper as the single owner of
resetting _sectionPracticeActiveParent, _sectionPracticeSelected,
_sectionPracticeWholeSection, _sectionPracticeSavedPartIndex, and
_sectionPracticeRanges.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 532ed184-48d4-4dde-96cb-27a97bbcd171

📥 Commits

Reviewing files that changed from the base of the PR and between ca16e6f and d3e6357.

📒 Files selected for processing (6)
  • static/app.js
  • static/js/host.js
  • static/js/section-practice.js
  • tests/js/host_contract.test.js
  • tests/js/loop_api.test.js
  • tests/js/section_practice_dismiss.test.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • static/js/host.js

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.

1 participant