refactor(active-tab): rename tab-lock to active-tab for clarity

The "lock" framing was misleading — there's no OS-level mutex; this is
a soft coordinator that decides which tab is the active one. Rename
makes the role obvious at the import site.

- file: tab-lock.svelte.js → active-tab.svelte.js (+ test)
- export: tabLock → activeTab
- field: .frozen → .inactive (positive form: this tab is inactive)
- fn: startTabLock → watchActiveTab
- fn: reclaimTab → claimActiveTab
- BroadcastChannel name: loto_tab_lock → loto_active_tab

No behavior change. Banner copy already updated separately.
This commit is contained in:
tiennm99 committed 2026-04-30 21:34:06 +07:00
1 parent 64e496b777
commit 3d988e1fc4
5 files changed
+96 -93

No files matched your search

+1 -1
View File
@@ -23,7 +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/active-tab.svelte.js` | Single-active-tab coordinator via `BroadcastChannel`. New tab broadcasts `claim`; old tab sets `activeTab.inactive = true`. `+layout.svelte` mounts `watchActiveTab()` on boot and renders a fullscreen overlay banner ("Phiên Lô tô đang chạy ở tab khác. Nhấn để tiếp tục tại đây.") when inactive — nhấn calls `claimActiveTab()` which broadcasts back, inactivating the other tab in turn. Soft coordination only (cooperating tabs); 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. |
+61
View File
@@ -0,0 +1,61 @@
/**
* Single-active-tab coordinator. When the user opens the app in a
* second tab, the second tab broadcasts a claim and the first tab
* marks itself inactive until the user explicitly takes it back.
* Prevents double auto-call intervals, double localStorage writers,
* and overlapping audio across tabs of the same origin.
*
* Soft coordination only — relies on cooperating tabs, not OS-level
* locks. No-op in browsers without `BroadcastChannel` (legacy iOS
* Safari ≤15.4); the prior race remains in those edge environments.
*
* @module lib/active-tab
*/
const CHANNEL = "loto_active_tab";
const TAB_ID =
typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `tab-${Math.random().toString(36).slice(2)}-${Date.now()}`;
export const activeTab = $state({
/** True when another tab has claimed; this tab should pause its UI. */
inactive: false,
});
/** @type {BroadcastChannel | null} */
let bc = null;
/**
* Open the channel, listen for peer claims, and announce that THIS tab
* is now the active one. Returns a cleanup that closes the channel.
* Calling more than once is a no-op so layout HMR doesn't double-bind.
*/
export function watchActiveTab() {
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) {
activeTab.inactive = true;
}
};
bc.postMessage({ type: "claim", id: TAB_ID });
return () => {
if (bc) {
bc.close();
bc = null;
}
};
}
/**
* Take active status back from the inactive banner. Marks this tab
* active locally and broadcasts a claim — any peer freezes itself in
* turn so the handover is symmetric.
*/
export function claimActiveTab() {
activeTab.inactive = false;
if (bc) bc.postMessage({ type: "claim", id: TAB_ID });
}
@@ -44,41 +44,41 @@ afterEach(() => {
vi.unstubAllGlobals();
});
describe("tab-lock", () => {
it("startTabLock returns no-op cleanup when BroadcastChannel is missing", async () => {
describe("active-tab", () => {
it("watchActiveTab 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();
const mod = await import("./active-tab.svelte.js?nobc");
const cleanup = mod.watchActiveTab();
expect(typeof cleanup).toBe("function");
expect(mod.tabLock.frozen).toBe(false);
expect(mod.activeTab.inactive).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("second tab's claim marks the first tab inactive", async () => {
const tabA = await import("./active-tab.svelte.js?a");
const tabB = await import("./active-tab.svelte.js?b");
tabA.watchActiveTab();
expect(tabA.activeTab.inactive).toBe(false);
tabB.watchActiveTab();
expect(tabA.activeTab.inactive).toBe(true);
expect(tabB.activeTab.inactive).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("claimActiveTab reactivates self and inactivates the other", async () => {
const tabA = await import("./active-tab.svelte.js?ra-a");
const tabB = await import("./active-tab.svelte.js?ra-b");
tabA.watchActiveTab();
tabB.watchActiveTab();
expect(tabA.activeTab.inactive).toBe(true);
tabA.claimActiveTab();
expect(tabA.activeTab.inactive).toBe(false);
expect(tabB.activeTab.inactive).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();
it("watchActiveTab is idempotent within a single module", async () => {
const tab = await import("./active-tab.svelte.js?idem");
const c1 = tab.watchActiveTab();
const c2 = tab.watchActiveTab();
expect(typeof c1).toBe("function");
expect(typeof c2).toBe("function");
c1();
-58
View File
@@ -1,58 +0,0 @@
/**
* 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 });
}
+8 -8
View File
@@ -4,29 +4,29 @@
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";
activeTab,
claimActiveTab,
watchActiveTab,
} from "$lib/active-tab.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
// The active-tab cleanup is returned so HMR / route changes close the
// BroadcastChannel cleanly.
onMount(() => {
loadSettings();
loadMaster();
return startTabLock();
return watchActiveTab();
});
</script>
{#if tabLock.frozen}
{#if activeTab.inactive}
<button
type="button"
onclick={reclaimTab}
onclick={claimActiveTab}
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"