5.7 KiB
5.7 KiB
Implementation Plan: Decouple reloadAtCountdownZero from start()/stop()
Status
Hardening follow-up to Job 905. The store currently clears the
reloadOnZero handler inside stop() (line 197), and start() calls
stop() first (line 110). Because ChallengesPage registers its handler
from the constructor while HomeComponent calls start()/stop()
independently, a stop() triggered by the shell's lifecycle can null out
the page's handler before the page is destroyed. The fix is to scope
reloadOnZero ownership to the subscriber (the challenges page)
and make start()/stop() only manage the SSE source + tick, never the
reload handler.
1. Architectural Reconventions (recap)
EventStatusStoreis aprovidedIn: 'root'singleton (frontend/src/app/core/services/event-status.store.ts).start(...)is called fromHomeComponent.ngOnInit(features/home/home.component.ts:132);stop()is invoked from bothngOnDestroy()anddestroyRef.onDestroy(...)in that same component (features/home/home.component.ts:125, 142).ChallengesPagecurrently registers its reload handler in its constructor (features/challenges/challenges.page.ts:128-133).- Tests live in
tests/frontend/*, runnable vianpm test. No new dependencies; no backend changes.
2. Impacted Files
- To Modify:
frontend/src/app/core/services/event-status.store.ts— separate handler ownership from SSE/timer ownership.frontend/src/app/features/challenges/challenges.page.ts— own the subscription, unregister inngOnDestroy(or viaDestroyRef).
- To Create: None.
3. Proposed Changes
3.1 EventStatusStore — split "transport lifecycle" from "reload subscription"
- Add a per-subscription API that returns an unregister function:
Keep the existing
subscribeReloadAtCountdownZero(handler: () => void): () => void { this.reloadOnZero = handler; this.reloadOnZeroFired = false; this.ensureWatcher(); return () => { if (this.reloadOnZero === handler) { this.reloadOnZero = null; this.reloadOnZeroFired = false; } }; }reloadAtCountdownZero(handler)as a thin wrapper that callssubscribeReloadAtCountdownZero(handler)and discards the returned disposer (preserves backwards compatibility with the four existing tests that call it directly). - In
start(...): remove thethis.stop()call.start()should only (a) close any previous source via a new privatecloseTransport(), (b) open the new source, (c) install the message handler, (d) start the tick interval, (e)ensureWatcher(). It must NOT touchreloadOnZero,reloadOnZeroFired, orlastAppliedJson. - Introduce
closeTransport()(private) that nullssource/intervalId/zeroWatcherONLY — no handler reset. The watcher interval is also bound to the transport lifecycle, butensureWatcher()keeps it idempotent, so closing it is fine. - In
stop(): keep the current teardown for source + tick + watcher, but only clearreloadOnZero/reloadOnZeroFired/lastAppliedJsonwhen called via the destroy hook (i.e.,stop()retains its existing full-reset semantics for end-of-life). To preserve that for the existing destroyRef hook (constructor() { destroyRef.onDestroy(() => this.stop()); }), keepstop()as the full reset. The crucial change is step 2:start()no longer callsstop(), so handler state survives astart()cycle.
3.2 ChallengesPage — own the subscription
- Replace the constructor's
this.eventStatus.reloadAtCountdownZero(...)call with:This keeps the subscription active acrossconst unsub = this.eventStatus.subscribeReloadAtCountdownZero(() => { if (typeof window !== 'undefined') window.location.reload(); }); this.destroyRef.onDestroy(() => unsub());start()re-entry on the store, registers the handler as early as possible (constructor), and unregisters when the page is destroyed. - The existing
this.destroyRef.onDestroy(() => this.store.stop())forChallengesStoreis unrelated and stays.
3.3 Tests
- New spec (
tests/frontend/event-status.store.watcher.spec.ts, add one case):start()after asubscribeReloadAtCountdownZeromust NOT clear the registered handler — fire a fake SSE delivery that triggersapplyServerStatuswith a zero-countdown frame, then advance fake timers and assert the handler still fires exactly once. - Update the existing
tests/frontend/challenges-page.spec.tsregression test "repeating the same zero-countdown SSE frame does not disarm the auto-reload latch (Job 905)" — no behaviour change required; it already exercisesapplyServerStatusdirectly and continues to pass. - New spec for
subscribeReloadAtCountdownZerounregister semantics: subscribe → unregister → apply zero-countdown → advance timers → handler must NOT be called.
3.4 Documentation
- Append one sentence to
docs/guides/event-window.md's "Auto-reload at the transition boundary" paragraph noting that the subscription is now page-owned and unregistered on destroy, independent of the transport'sstart()/stop().
4. Test Strategy
- Single command:
npm test. New assertions targettests/frontend/event-status.store.watcher.spec.tsand use fake timers (already used in this file). No mocking ofwindow.locationis required — the handler under test is a plainjest.fn().
5. Non-goals
- No backend changes, no new dependencies, no
setup.shchange. - No changes to other consumers of
EventStatusStore(HomeComponent'sstart()/stop()cycle is preserved by step 3.1.2).