From 1cc6c0d56e18131a856b1016bb443434c7cc68c5 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 14 Jun 2026 12:39:57 +0200 Subject: [PATCH] fix(ui): stop the game-screen freeze when an opponent joins (reactive self-loop) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ui/e2e/quickmatch.spec.ts | 14 ++++++++++ ui/src/game/Game.svelte | 55 +++++++++++++++++++++++---------------- 2 files changed, 46 insertions(+), 23 deletions(-) diff --git a/ui/e2e/quickmatch.spec.ts b/ui/e2e/quickmatch.spec.ts index 4e3b388..9271dc2 100644 --- a/ui/e2e/quickmatch.spec.ts +++ b/ui/e2e/quickmatch.spec.ts @@ -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); +}); diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index cc7aa14..580d93a 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -1,5 +1,5 @@