Compare commits

...

2 Commits

Author SHA1 Message Date
Ilia Denisov 553152e195 refactor(ui): harden the board's recenter dependency and gate it on a real bump
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m0s
The recenter effect declared its dependency with a bare `void recenter`, which a
production minifier could drop as a side-effect-free statement (the e2e runs only
the unminified dev server, so it would not catch that). Read the nonce into a
used variable and gate the pan on it actually changing (`recentered`), so the
reactive dependency is robust to minification and the board pans only on an
explicit hint recenter — never on an incidental re-run such as a viewport resize.
2026-06-14 12:40:08 +02:00
Ilia Denisov 1cc6c0d56e fix(ui): stop the game-screen freeze when an opponent joins (reactive self-loop)
The in-game live-event $effect read `view`/`moves`/`placement` (via cacheSnapshot
and direct reads) AND wrote `view` in its branches, so those reads became the
effect's own dependencies: writing `view = …` re-ran the effect, and with
app.lastEvent unchanged it re-entered the same branch and wrote `view` again — a
tight self-invalidating loop that pinned the main thread, freezing the board and
rack. opponent_moved escaped it only because applyMoveDelta is idempotent on the
move count (no cache → no write on the second pass); opponent_joined and game_over
have no such guard, so an opponent joining hung the whole screen. Tracking
`placement` similarly re-fired the handler on every tile the player placed after
an opponent's move (a spurious reload).

Fix: the effect must depend only on app.lastEvent and process each event once.
Wrap the branch body in `untrack`, scoping its view/moves/placement reads out of
the effect's dependency set; the writes inside no longer re-trigger it.

e2e: after the opponent joins, placing a rack tile must render a pending tile —
verified RED (frozen, 0 pending tiles, both engines) before the fix, GREEN after.
2026-06-14 12:39:57 +02:00
3 changed files with 58 additions and 29 deletions
+14
View File
@@ -76,3 +76,17 @@ test('quick game: a foreground resync recovers a join shed while the stream stay
await expect(page.getByText('Robo')).toBeVisible(); await expect(page.getByText('Robo')).toBeVisible();
await expect(page.getByText(/Searching for opponent/)).toHaveCount(0); await expect(page.getByText(/Searching for opponent/)).toHaveCount(0);
}); });
// Regression: an opponent joining must not freeze the screen. The in-game live-event effect tracked
// the `view` it also writes, so opponent_joined re-invalidated itself in a tight loop (opponent_moved
// was spared only by its delta's move-count idempotency). The board must still respond afterwards.
test('quick game: the board stays interactive after the opponent joins', async ({ page }) => {
await enterOpenGame(page);
await page.evaluate(() => (window as unknown as { __mock: { joinOpponent(): void } }).__mock.joinOpponent());
await expect(page.getByText('Robo')).toBeVisible();
// Place a rack tile on the centre star: a frozen screen renders no pending tile.
await page.locator('.rack .tile').first().click();
await page.locator('[data-cell]:not(.filled)').nth(112).click(); // row 7, col 7
await expect(page.locator('[data-cell].pending')).toHaveCount(1);
});
+12 -6
View File
@@ -69,12 +69,16 @@
// toggle or an explicit recenter request (the hint) moves it. A recenter with no zoom change // toggle or an explicit recenter request (the hint) moves it. A recenter with no zoom change
// pans straight to `focus`, since the board width is stable and the width-driven tween below // pans straight to `focus`, since the board width is stable and the width-driven tween below
// has nothing to ride (the case the owner hit: a hint taken while already zoomed in). // has nothing to ride (the case the owner hit: a hint taken while already zoomed in).
// The previous zoom state, to tell a zoom toggle from a recenter request. The board always mounts // prevZoom/prevRecenter let the effect tell a zoom toggle from a recenter request. The board
// zoomed-out, so it starts false and is updated to the live zoom on each effect run. // always mounts zoomed-out, so prevZoom starts false; both are updated on each effect run.
let prevZoom = false; let prevZoom = false;
let prevRecenter = 0;
$effect(() => { $effect(() => {
const on = zoomed; const on = zoomed;
void recenter; // re-run on an explicit recenter request even when the zoom state is unchanged // An explicit "scroll to focus now" request (the hint). Read into a variable that is USED below,
// so the reactive dependency survives minification (a bare `void recenter` could be dropped) and
// an incidental re-run never pans on its own.
const rc = recenter;
const vp = viewport; const vp = viewport;
if (!vp) return; if (!vp) return;
const f = untrack(() => focus); const f = untrack(() => focus);
@@ -91,11 +95,13 @@
finalST = Math.max(0, Math.min((f.row + 0.5) * cell - clientH / 2, fullW - clientH)); finalST = Math.max(0, Math.min((f.row + 0.5) * cell - clientH / 2, fullW - clientH));
} }
const toggled = on !== prevZoom; const toggled = on !== prevZoom;
const recentered = rc !== prevRecenter;
prevZoom = on; prevZoom = on;
prevRecenter = rc;
if (!toggled) { if (!toggled) {
// No zoom change: an explicit recenter (the hint) pans straight to the focused word; a stale // No zoom change: pan straight to the focused word only on an explicit recenter request (the
// run with nothing focused leaves the scroll alone. // hint), never on an incidental re-run such as a viewport resize.
if (on && f) { if (recentered && on && f) {
vp.scrollLeft = finalSL; vp.scrollLeft = finalSL;
vp.scrollTop = finalST; vp.scrollTop = finalST;
} }
+32 -23
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount, untrack } from 'svelte';
import Screen from '../components/Screen.svelte'; import Screen from '../components/Screen.svelte';
import TabBar from '../components/TabBar.svelte'; import TabBar from '../components/TabBar.svelte';
import TapConfirm from '../components/TapConfirm.svelte'; import TapConfirm from '../components/TapConfirm.svelte';
@@ -222,29 +222,38 @@
$effect(() => { $effect(() => {
const e = app.lastEvent; const e = app.lastEvent;
if (!e) return; if (!e) return;
if (e.kind === 'opponent_moved' && e.gameId === id) { // Depend only on app.lastEvent: process each event once. The branches below READ and WRITE
// While composing, reload so a draft overlapping the new move is reconciled; otherwise apply // view/moves/placement, so tracking those reads would re-run this effect from its own
// the move as a delta with no fetch. // `view = …` write — with app.lastEvent unchanged it re-enters the same branch, a tight loop.
if (placement.pending.length > 0) void load(); // opponent_moved self-limits via the delta's move-count idempotency, but opponent_joined (and
else applyDelta(applyMoveDelta(cacheSnapshot(), { move: e.move, game: e.game, bagLen: e.bagLen })); // game_over) do not, so an opponent joining froze the whole game screen. (Tracking placement
} else if (e.kind === 'your_turn' && e.gameId === id) { // likewise re-fired the handler on every tile a player placed.) untrack scopes the body's reads
// The opponent_moved delta carries the new state; your_turn only confirms the turn. Refetch // out of the effect's dependencies.
// only if we missed the move (our cached count trails the event's). untrack(() => {
if (view && e.moveCount > view.game.moveCount) void load(); if (e.kind === 'opponent_moved' && e.gameId === id) {
} else if (e.kind === 'game_over' && e.gameId === id) { // While composing, reload so a draft overlapping the new move is reconciled; otherwise apply
applyDelta(applyGameOver(cacheSnapshot(), e.game)); // the move as a delta with no fetch.
} else if (e.kind === 'opponent_joined' && e.gameId === id && e.state) { if (placement.pending.length > 0) void load();
// The opponent took the empty seat: adopt the new participants and status in place, else applyDelta(applyMoveDelta(cacheSnapshot(), { move: e.move, game: e.game, bagLen: e.bagLen }));
// leaving the board, rack and any pending placement untouched (no refetch, no flicker). } else if (e.kind === 'your_turn' && e.gameId === id) {
const r = applyOpponentJoined(cacheSnapshot(), e.state); // The opponent_moved delta carries the new state; your_turn only confirms the turn. Refetch
if (r) { // only if we missed the move (our cached count trails the event's).
view = r.view; if (view && e.moveCount > view.game.moveCount) void load();
setCachedGame(id, r.view, r.moves); } else if (e.kind === 'game_over' && e.gameId === id) {
applyDelta(applyGameOver(cacheSnapshot(), e.game));
} else if (e.kind === 'opponent_joined' && e.gameId === id && e.state) {
// The opponent took the empty seat: adopt the new participants and status in place,
// leaving the board, rack and any pending placement untouched (no refetch, no flicker).
const r = applyOpponentJoined(cacheSnapshot(), e.state);
if (r) {
view = r.view;
setCachedGame(id, r.view, r.moves);
}
} else if (e.kind === 'notify' && (e.sub === 'friend_added' || e.sub === 'friend_declined')) {
// A request the player sent was answered: re-derive the in-game "add friend" state.
void loadFriends();
} }
} else if (e.kind === 'notify' && (e.sub === 'friend_added' || e.sub === 'friend_declined')) { });
// A request the player sent was answered: re-derive the in-game "add friend" state.
void loadFriends();
}
}); });
// The live-event hub is best-effort and never replays (see lib/app.svelte.ts): an event dropped // The live-event hub is best-effort and never replays (see lib/app.svelte.ts): an event dropped