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.
This commit is contained in:
Ilia Denisov
2026-06-14 12:39:57 +02:00
parent f3914af793
commit 1cc6c0d56e
2 changed files with 46 additions and 23 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(/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);
});
+10 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { onDestroy, onMount, untrack } from 'svelte';
import Screen from '../components/Screen.svelte';
import TabBar from '../components/TabBar.svelte';
import TapConfirm from '../components/TapConfirm.svelte';
@@ -222,6 +222,14 @@
$effect(() => {
const e = app.lastEvent;
if (!e) return;
// Depend only on app.lastEvent: process each event once. The branches below READ and WRITE
// view/moves/placement, so tracking those reads would re-run this effect from its own
// `view = …` write — with app.lastEvent unchanged it re-enters the same branch, a tight loop.
// opponent_moved self-limits via the delta's move-count idempotency, but opponent_joined (and
// game_over) do not, so an opponent joining froze the whole game screen. (Tracking placement
// likewise re-fired the handler on every tile a player placed.) untrack scopes the body's reads
// out of the effect's dependencies.
untrack(() => {
if (e.kind === 'opponent_moved' && e.gameId === id) {
// While composing, reload so a draft overlapping the new move is reconciled; otherwise apply
// the move as a delta with no fetch.
@@ -246,6 +254,7 @@
void loadFriends();
}
});
});
// The live-event hub is best-effort and never replays (see lib/app.svelte.ts): an event dropped
// while the stream is down — or shed from a full subscriber buffer — is gone for good.