feat(tab-lock): single-tab guard via BroadcastChannel

Two tabs of the app on the same origin both running auto-call would
double-draw, double-write `loto_master`, and overlap audio. New tab
now broadcasts a claim; old tab freezes itself with a fullscreen
overlay ("Loto đã mở ở tab khác. Tap để chuyển về tab này.") and the
user can tap to take it back — handover broadcasts a fresh claim,
freezing the other tab in turn.

Mounted in +layout.svelte's onMount so the cleanup closes the channel
on HMR / route changes. No-op in legacy iOS Safari (≤15.4) without
BroadcastChannel — silently falls through to the prior behavior.
This commit is contained in:
tiennm99 committed 2026-04-30 21:22:23 +07:00
1 parent 9e9c68bff6
commit 2f4efd1cab
4 files changed
+173 -1

No files matched your search

+1
View File
@@ -23,6 +23,7 @@
|------|---------|
| `src/lib/game-logic.js` | Stateless utilities: generateGrid (constraint-aware picker — exact 5 per row & per col, ascending-sorted columns, soft "no 3 consecutive filled cols per row" via rejection sampling), saveGrid, loadGrid, saveCrossedState, loadCrossedState, isRowComplete, getWaitingNumber. |
| `src/lib/master-store.svelte.js` | Shared reactive `{called, remaining}` $state for the master deck, persisted to `loto_master`. Exports `masterState`, `loadMaster`, `saveMaster`, `startNewGame`, `drawNext`, `resetMaster`. Hydrated once via `+layout.svelte`'s `onMount` so player-side reads see the full history regardless of mount order. Replaced the single-slot `call-bus` to fix history-loss bugs (regen, reload, multi-tab) — see `plans/reports/code-reviewer-260430-2024-both-mode-consistency.md`. |
| `src/lib/tab-lock.svelte.js` | Single-tab guard via `BroadcastChannel`. New tab broadcasts `claim`; old tab sets `tabLock.frozen = true`. `+layout.svelte` mounts it on boot and renders a fullscreen overlay banner ("Loto đã mở ở tab khác. Tap để chuyển về tab này.") when frozen — tap calls `reclaimTab` which broadcasts back, freezing the other tab in turn. No-op in browsers without `BroadcastChannel` (legacy iOS Safari ≤15.4). Prevents double auto-call intervals, double localStorage writers, and overlapping audio across tabs. |
| `src/lib/player-auto-cross.js` | Pure `applyMasterCalls({grid, crossed, called, lastHandledIndex, manualUnticks, mode})`. Cursor-by-index dedup (vs the retired `at`-timestamp model), so any caller can pass `lastHandledIndex: 0` to replay master's full history (used by player regen + "Xoá đánh dấu" in both mode). Manual unticks suppress re-cross on replay. |
| `src/lib/vietnamese-number.js` | `numberToVietnamese(n)` — pure utility mapping 0..90 to spoken Vietnamese, with tonal exceptions (15 → "mười lăm", 21 → "hai mươi mốt", 25 → "hai mươi lăm"). Out-of-range falls back to `String(n)`. |
| `src/lib/voice.js` | Bundled-MP3 playback. Exports `playNumber(n)`, `playWaiting(n)` (sequences cho + N), `playBingo()`, `cancelPlayback()`. Lazy `<audio>` cache, cancel-then-play, token-based cancel ensures stale promises can't resume after a new event. Reads active voice from `settings.voice`; URLs go through `import { base } from "$app/paths"` for basePath safety. |
+58
View File
@@ -0,0 +1,58 @@
/**
* Single-tab guard for the Lô tô app. When the user opens the app in a
* second tab, the second tab broadcasts a claim and the first tab
* freezes itself until the user explicitly takes it back. Prevents
* double auto-call intervals, double localStorage writers, and
* overlapping audio across tabs of the same origin.
*
* No-op in browsers without `BroadcastChannel` (legacy iOS Safari).
*
* @module lib/tab-lock
*/
const CHANNEL = "loto_tab_lock";
const TAB_ID =
typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `tab-${Math.random().toString(36).slice(2)}-${Date.now()}`;
export const tabLock = $state({
/** True when another tab has taken over and this tab should pause. */
frozen: false,
});
/** @type {BroadcastChannel | null} */
let bc = null;
/**
* Start listening for tab-claim messages and immediately announce that
* THIS tab is now active. Returns a cleanup that closes the channel.
* Calling more than once is a no-op so layout HMR doesn't double-bind.
*/
export function startTabLock() {
if (typeof BroadcastChannel === "undefined") return () => {};
if (bc) return () => {};
bc = new BroadcastChannel(CHANNEL);
bc.onmessage = (e) => {
if (e.data?.type === "claim" && e.data.id !== TAB_ID) {
tabLock.frozen = true;
}
};
bc.postMessage({ type: "claim", id: TAB_ID });
return () => {
if (bc) {
bc.close();
bc = null;
}
};
}
/**
* Re-claim the lock from the frozen banner. Unfreezes this tab and
* broadcasts a new claim — any other live tab freezes itself in turn.
*/
export function reclaimTab() {
tabLock.frozen = false;
if (bc) bc.postMessage({ type: "claim", id: TAB_ID });
}
+87
View File
@@ -0,0 +1,87 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/** Minimal stub matching the BroadcastChannel surface we use. */
class FakeBC {
/** @type {Map<string, FakeBC[]>} */
static channels = new Map();
/** @param {string} name */
constructor(name) {
this.name = name;
this.onmessage = null;
this.closed = false;
const peers = FakeBC.channels.get(name) ?? [];
peers.push(this);
FakeBC.channels.set(name, peers);
}
/** @param {any} data */
postMessage(data) {
if (this.closed) return;
const peers = FakeBC.channels.get(this.name) ?? [];
for (const p of peers) {
if (p === this || p.closed || !p.onmessage) continue;
p.onmessage({ data });
}
}
close() {
this.closed = true;
const peers = FakeBC.channels.get(this.name) ?? [];
FakeBC.channels.set(
this.name,
peers.filter((p) => p !== this),
);
}
}
beforeEach(() => {
FakeBC.channels.clear();
vi.stubGlobal("BroadcastChannel", /** @type {any} */ (FakeBC));
vi.resetModules();
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("tab-lock", () => {
it("startTabLock returns no-op cleanup when BroadcastChannel is missing", async () => {
vi.stubGlobal("BroadcastChannel", undefined);
const mod = await import("./tab-lock.svelte.js?nobc");
const cleanup = mod.startTabLock();
expect(typeof cleanup).toBe("function");
expect(mod.tabLock.frozen).toBe(false);
cleanup();
});
it("second tab's claim freezes the first tab", async () => {
const tabA = await import("./tab-lock.svelte.js?a");
const tabB = await import("./tab-lock.svelte.js?b");
tabA.startTabLock();
expect(tabA.tabLock.frozen).toBe(false);
tabB.startTabLock();
expect(tabA.tabLock.frozen).toBe(true);
expect(tabB.tabLock.frozen).toBe(false);
});
it("reclaimTab unfreezes self and freezes the other", async () => {
const tabA = await import("./tab-lock.svelte.js?ra-a");
const tabB = await import("./tab-lock.svelte.js?ra-b");
tabA.startTabLock();
tabB.startTabLock();
expect(tabA.tabLock.frozen).toBe(true);
tabA.reclaimTab();
expect(tabA.tabLock.frozen).toBe(false);
expect(tabB.tabLock.frozen).toBe(true);
});
it("startTabLock is idempotent within a single module", async () => {
const tab = await import("./tab-lock.svelte.js?idem");
const c1 = tab.startTabLock();
const c2 = tab.startTabLock();
expect(typeof c1).toBe("function");
expect(typeof c2).toBe("function");
c1();
c2();
});
});
+27 -1
View File
@@ -3,16 +3,42 @@
import "../app.css";
import { loadMaster } from "$lib/master-store.svelte.js";
import { loadSettings } from "$lib/settings-store.svelte.js";
import {
reclaimTab,
startTabLock,
tabLock,
} from "$lib/tab-lock.svelte.js";
let { children } = $props();
// Hydrate global stores once so children mount with consistent state.
// Master state must hydrate before PlayerBoard reads `masterState.called`
// length — see the mode-mount race fix in the consistency refactor.
// The tab-lock cleanup is returned so HMR / route changes close the
// BroadcastChannel cleanly.
onMount(() => {
loadSettings();
loadMaster();
return startTabLock();
});
</script>
{@render children()}
{#if tabLock.frozen}
<button
type="button"
onclick={reclaimTab}
class="fixed inset-0 z-50 flex flex-col items-center justify-center
bg-slate-900/95 text-white text-center px-6
cursor-pointer focus:outline-none"
aria-label="Loto đã mở ở tab khác. Nhấn để chuyển về tab này."
>
<p class="text-2xl sm:text-3xl font-bold mb-3">
🏮 Loto đã mở ở tab khác
</p>
<p class="text-base text-slate-300 max-w-sm">
Tap để chuyển về tab này. Tab kia sẽ tự dừng.
</p>
</button>
{:else}
{@render children()}
{/if}