Presenter view and remote: stop reloading in a loop
present.js reads the slide a page is showing from data-slide on its root. present_notes.html and remote.html never set it, so the script took both pages to be on slide 0, the stream's first event (slide 1) "moved" them, and they reloaded — on every load, ~50 times a second (polish audit B3). - Both roots now carry data-slide (presented.Now, the same 1-based number the stream sends). - The two reload-to-follow pages go through follow(): reload only when the event's slide or blackout differs from what was rendered, and at most once per state, remembered in sessionStorage across exactly one reload. A root without data-slide, or a session pointing past a deck that got shorter (the server clamps), now costs one reload, not a loop. - Blackout changes now reload those two pages too: both render the blackout pill and the button label server-side. Tests: TestEveryPresentingPageSaysWhichSlideItShows (plain suite) and TestBrowserPresentingScreensHoldStill (-tags browser: counts top-level navigations; 102/75 per 3 s before, 0 after; exactly one per slide move).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+194
−9
internal/slides/presentation.go+12 −2internal/slides/static/present.js+42 −7internal/slides/templates/present_notes.html+1 −0internal/slides/templates/remote.html+1 −0internal/slidestest/browser_present_test.go+108 −0internal/slidestest/present_test.go+30 −0
diff --git a/internal/slides/presentation.go b/internal/slides/presentation.go| index 8cf083a..4e99a54 100644 |
| --- a/internal/slides/presentation.go |
| +++ b/internal/slides/presentation.go |
| @@ -472,6 +472,7 @@ type presenterPage struct { |
| Session string |
| Token string |
| + Now int |
| Counter string |
| CurrentLabel string |
| Current template.HTML |
| @@ -521,6 +522,7 @@ type remotePage struct { |
| DeckTitle string |
| Session string |
| + Now int |
| Counter string |
| CurrentLabel string |
| Current template.HTML |
| @@ -792,7 +794,7 @@ func (a *app) presenterNotes(w http.ResponseWriter, r *http.Request) { |
| ToolsLabel: rastrillo.T(r, "slides.viewer.tools"), |
| } |
| shown := presentedFor(r, view, p) |
| - page.Counter, page.Current = shown.Counter, shown.Current |
| + page.Now, page.Counter, page.Current = shown.Now, shown.Counter, shown.Current |
| page.Next, page.HasNext, page.Notes = shown.Next, shown.HasNext, shown.Notes |
| if holder { |
| @@ -845,6 +847,13 @@ func clockWords(t time.Time) string { |
| // a hotel wifi. The projector at /p/{session} still gets the real |
| // rendering, because that one is the slide. |
| type presented struct { |
| + // Now is the 1-based slide these were rendered for — the number the |
| + // stream's events carry — and it goes on the page's script root as |
| + // data-slide, so static/present.js knows what it is already showing. |
| + // Without it the script took the page to be on slide 0, the first |
| + // event "moved" it, and the page reloaded itself on every load |
| + // (polish audit B3). |
| + Now int |
| Counter string |
| Current template.HTML |
| Next template.HTML |
| @@ -863,6 +872,7 @@ func presentedFor(r *http.Request, view *deck.Deck, p Presentation) presented { |
| } |
| index := clampSlide(p.Slide, count) |
| out := presented{ |
| + Now: index + 1, |
| Counter: rastrillo.Tf(r, "slides.present.progress", "n", index+1, "total", count), |
| Current: slideThumbImg(r, view, p, index), |
| } |
| @@ -965,7 +975,7 @@ func (a *app) remote(w http.ResponseWriter, r *http.Request) { |
| ToolsLabel: rastrillo.T(r, "slides.viewer.tools"), |
| } |
| shown := presentedFor(r, view, p) |
| - page.Counter, page.Current = shown.Counter, shown.Current |
| + page.Now, page.Counter, page.Current = shown.Now, shown.Counter, shown.Current |
| page.Next, page.HasNext, page.Notes = shown.Next, shown.HasNext, shown.Notes |
| render(w, "remote", page) |
| } |
diff --git a/internal/slides/static/present.js b/internal/slides/static/present.js| index 8d46682..5cc4509 100644 |
| --- a/internal/slides/static/present.js |
| +++ b/internal/slides/static/present.js |
| @@ -105,13 +105,6 @@ |
| // event this page understands and is left alone rather than fetched. |
| if (!(slide > 0) || slide === requested) return; |
| requested = slide; |
| - if (!stage || !fragmentHref) { |
| - // The presenter view and the remote show two slides and the |
| - // notes; re-rendering all that in here would be a second |
| - // renderer to keep in step with the Go one. |
| - window.location.reload(); |
| - return; |
| - } |
| var mine = ++newest; |
| var back = function () { |
| // Only this request's own failure rolls the record back, and |
| @@ -131,12 +124,54 @@ |
| .catch(back); |
| } |
| + // The pages with no stage to swap — the presenter view and the |
| + // remote — follow by reloading: they show two slides, the notes and |
| + // the blackout state, and re-rendering all that in here would be a |
| + // second renderer to keep in step with the Go one. Their root says |
| + // what they were rendered with (data-slide, data-blackout), and they |
| + // reload only when an event says something else. |
| + // |
| + // And at most once for any one state. A page that has just reloaded |
| + // for slide 3 and still says something else — a root missing its |
| + // data-slide, or a session pointing past a deck that has since got |
| + // shorter, which the server clamps — would otherwise reload again on |
| + // the stream's first event, and again after that, forever: the |
| + // presenter view once did exactly that about fifty times a second |
| + // (polish audit B3). The record is taken off at load, so it only |
| + // ever guards the load straight after the reload that wrote it. |
| + var renderedBlackout = root.getAttribute('data-blackout') === 'true'; |
| + var reloadKey = 'slides-present-reload:' + window.location.pathname; |
| + var reloadedFor = null; |
| + try { |
| + reloadedFor = window.sessionStorage.getItem(reloadKey); |
| + window.sessionStorage.removeItem(reloadKey); |
| + } catch (err) { /* no storage: see follow */ } |
| + |
| + function follow(state) { |
| + var slide = Number(state.slide); |
| + if (!(slide > 0)) return; |
| + var blackout = !!state.blackout; |
| + if (slide === displayed && blackout === renderedBlackout) return; |
| + var want = String(slide) + (blackout ? ':blackout' : ''); |
| + if (want === reloadedFor) return; |
| + try { |
| + window.sessionStorage.setItem(reloadKey, want); |
| + } catch (err) { |
| + // Nowhere to remember that this reload happened, so only reload |
| + // a page that at least knows which slide it is on. |
| + if (!displayed) return; |
| + } |
| + reloadedFor = want; |
| + window.location.reload(); |
| + } |
| + |
| // apply is the whole of what an event does. The state is complete — |
| // never a delta — so applying one is exactly right rather than |
| // approximately, and a page that missed some is right again on the |
| // next one. |
| function apply(state) { |
| if (state.ended) { window.location.reload(); return; } |
| + if (!stage || !fragmentHref) { follow(state); return; } |
| root.setAttribute('data-blackout', state.blackout ? 'true' : 'false'); |
| if (note) note.hidden = !state.blackout; |
| show(Number(state.slide)); |
diff --git a/internal/slides/templates/present_notes.html b/internal/slides/templates/present_notes.html| index 221dd2b..d8d9469 100644 |
| --- a/internal/slides/templates/present_notes.html |
| +++ b/internal/slides/templates/present_notes.html |
| @@ -110,6 +110,7 @@ |
| A presenter view opened without the token watches and cannot |
| drive, which is exactly what the buttons above do too. */}} |
| <div slides-preview data-session="{{.Session}}" |
| + data-slide="{{.Now}}" |
| data-blackout="{{if .Blackout}}true{{else}}false{{end}}" |
| {{- if .Token}} data-remote-token="{{.Token}}"{{end}}> |
| <div> |
diff --git a/internal/slides/templates/remote.html b/internal/slides/templates/remote.html| index db74c99..8e984aa 100644 |
| --- a/internal/slides/templates/remote.html |
| +++ b/internal/slides/templates/remote.html |
| @@ -61,6 +61,7 @@ |
| {{template "page-header" dict "Title" .Title "Sub" .DeckTitle}} |
| <div slides-remote data-session="{{.Session}}" data-remote-token="{{.Controls.Token}}" |
| + data-slide="{{.Now}}" |
| data-blackout="{{if .Blackout}}true{{else}}false{{end}}"> |
| <div slides-remote-preview> |
| <div> |
diff --git a/internal/slidestest/browser_present_test.go b/internal/slidestest/browser_present_test.go| new file mode 100644 |
| index 0000000..13ed87b |
| --- /dev/null |
| +++ b/internal/slidestest/browser_present_test.go |
| @@ -0,0 +1,108 @@ |
| +//go:build browser |
| + |
| +// The presenting screens with their script on. The Go tests read the |
| +// markup; only a browser runs static/present.js against it, and the |
| +// failure this file was written for (polish audit B3, 2026-09-23) was |
| +// the script and the markup disagreeing: the presenter view and the |
| +// remote carried no data-slide, so the page believed it was on slide |
| +// 0, the stream's first event said slide 1, and the page reloaded — |
| +// and did the same on every load after, about fifty times a second. |
| +package slidestest |
| + |
| +import ( |
| + "regexp" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| + |
| + "github.com/chromedp/cdproto/page" |
| + "github.com/chromedp/chromedp" |
| + "github.com/chromedp/chromedp/kb" |
| +) |
| + |
| +var notesLocation = regexp.MustCompile(`^(/p/[23456789abcdefghjkmnpqrstuvwxyz]{10})/notes\?t=([A-Za-z0-9_-]{43})$`) |
| + |
| +// TestBrowserPresentingScreensHoldStill: the presenter view and the |
| +// phone remote each load once and then stay put while nothing |
| +// changes, and each reloads exactly once when the slide does move — |
| +// which is how those two pages follow a talk (present.js reloads them |
| +// rather than re-rendering two slides and the notes itself). |
| +func TestBrowserPresentingScreensHoldStill(t *testing.T) { |
| + rig, mailer := newRig(t) |
| + browserSignIn(t, rig, mailer) |
| + |
| + // Every top-level navigation the tab makes, reloads included. |
| + var loads atomic.Int64 |
| + chromedp.ListenTarget(rig.Context(), func(ev any) { |
| + if e, ok := ev.(*page.EventFrameNavigated); ok && e.Frame.ParentID == "" { |
| + loads.Add(1) |
| + } |
| + }) |
| + |
| + var deck string |
| + rig.Run(chromedp.Evaluate(`(document.querySelector('a[href^="/d/"]')?.getAttribute('href') ?? '')`, &deck)) |
| + if !deckPath.MatchString(deck) { |
| + t.Fatalf("the deck list links no deck: %q", deck) |
| + } |
| + |
| + var path string |
| + rig.Run( |
| + chromedp.Navigate(rig.Origin+deck), |
| + chromedp.Click(`form[action$="/present"] button`, chromedp.ByQuery), |
| + waitFor(`location.pathname.endsWith('/notes')`), |
| + chromedp.Evaluate(`location.pathname + location.search`, &path), |
| + ) |
| + m := notesLocation.FindStringSubmatch(path) |
| + if m == nil { |
| + t.Fatalf("Present landed on %q, want /p/{session}/notes?t={token}", path) |
| + } |
| + session, token := m[1], m[2] |
| + |
| + // holdsStill waits for the page to settle and the stream to have |
| + // said its piece (one event on connect), then counts loads over a |
| + // window long enough for the broken page to have reloaded hundreds |
| + // of times. |
| + holdsStill := func(name string) { |
| + t.Helper() |
| + rig.Run(chromedp.Sleep(1500 * time.Millisecond)) |
| + before := loads.Load() |
| + rig.Run(chromedp.Sleep(3 * time.Second)) |
| + if n := loads.Load() - before; n != 0 { |
| + t.Errorf("the %s loaded %d more times in 3s with nothing changing, want 0", name, n) |
| + } |
| + // And it holds still because it knows where it is, not only |
| + // because present.js refuses to reload twice for one slide: |
| + // the script's root names the slide the counter shows. |
| + var marked, counter string |
| + rig.Run( |
| + chromedp.Evaluate(`document.querySelector('[data-session]')?.getAttribute('data-slide') ?? 'missing'`, &marked), |
| + chromedp.Evaluate(`(document.querySelector('[slides-counter]')?.textContent ?? '').trim().split(' ')[0]`, &counter), |
| + ) |
| + if marked != counter { |
| + t.Errorf("the %s's script root says data-slide=%q; the counter shows slide %q", name, marked, counter) |
| + } |
| + } |
| + // movesOnce presses the right arrow — present.js posts `next` — and |
| + // the page must reload onto the new slide once, and only once. |
| + movesOnce := func(name, counter string) { |
| + t.Helper() |
| + before := loads.Load() |
| + rig.Run( |
| + chromedp.KeyEvent(kb.ArrowRight), |
| + waitFor(`document.querySelector('[slides-counter]')?.textContent.trim().startsWith('`+counter+` /')`), |
| + chromedp.Sleep(3*time.Second), |
| + ) |
| + if n := loads.Load() - before; n != 1 { |
| + t.Errorf("the %s loaded %d times after one slide change, want exactly 1", name, n) |
| + } |
| + } |
| + |
| + holdsStill("presenter view") |
| + rig.Screen("body", "presenter view") |
| + movesOnce("presenter view", "2") |
| + |
| + rig.Run(chromedp.Navigate(rig.Origin + session + "/remote?t=" + token)) |
| + holdsStill("remote") |
| + rig.Screen("body", "remote") |
| + movesOnce("remote", "3") |
| +} |
diff --git a/internal/slidestest/present_test.go b/internal/slidestest/present_test.go| index b482905..fd69a71 100644 |
| --- a/internal/slidestest/present_test.go |
| +++ b/internal/slidestest/present_test.go |
| @@ -567,3 +567,33 @@ func TestFollowOfferedOnlyWhileLive(t *testing.T) { |
| t.Errorf("following an ended session: status %d, want 303 back to the deck", rec.Code) |
| } |
| } |
| + |
| +// TestEveryPresentingPageSaysWhichSlideItShows: every page that loads |
| +// static/present.js puts the slide it was rendered for on the script's |
| +// root as data-slide, the same 1-based number the stream's events |
| +// carry. The presenter view and the remote once had none, so the |
| +// script took them to be on slide 0 and reloaded on every event — |
| +// the stream's first included — forever (polish audit B3). The |
| +// browser half is TestBrowserPresentingScreensHoldStill; this one is |
| +// in the plain suite so the gate holds it too. |
| +func TestEveryPresentingPageSaysWhichSlideItShows(t *testing.T) { |
| + a := newApp(t) |
| + cookies := signedIn(t, a, allowedAddress) |
| + path := sampleDeck(t, a, cookies) |
| + session, token := startPresenting(t, a, path, cookies) |
| + command(t, a, session, "next", token, "", nil) |
| + |
| + for _, page := range []string{ |
| + "/p/" + session + "?t=" + token, |
| + "/p/" + session + "/notes?t=" + token, |
| + "/p/" + session + "/remote?t=" + token, |
| + } { |
| + body := mustGet(t, a, page, http.StatusOK, cookies).Body.String() |
| + if !strings.Contains(body, `static/present`) { |
| + t.Errorf("%s no longer loads present.js; this test is about the pages that do", page) |
| + } |
| + if !strings.Contains(body, `data-slide="2"`) { |
| + t.Errorf("%s: the script root does not say data-slide=\"2\" after one next:\n%s", page, body) |
| + } |
| + } |
| +} |