feat: final bug fixes

This commit is contained in:
2026-07-26 16:31:06 +09:30
parent 318000009f
commit d5882e2d66
20 changed files with 534 additions and 114 deletions
+1 -1
View File
@@ -48,7 +48,7 @@
tabindex={editMode ? -1 : 0}
aria-label={item.label}
aria-current={active ? "page" : undefined}
in:fly={{ x: drillEnter ? 24 : 0, duration: drillEnter ? 180 : 0 }}
in:fly={drillEnter ? { x: 24, duration: 180 } : undefined}
onclick={(e) => {
// Keep SEQTA's #menu handlers from seeing custom-list clicks — that fights
// our drill UI and can freeze the tab (Goals / Folios / etc.).
+59 -20
View File
@@ -3,6 +3,7 @@ import type { SettingsState } from "@/types/storage";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import { waitForElm } from "@/seqta/utils/waitForElm";
import { waitForSeqtaMenu } from "@/seqta/utils/waitForSeqtaShell";
import Sidebar from "./Sidebar.svelte";
import { getNativeMenuList } from "./parseNativeMenu";
import {
@@ -28,30 +29,62 @@ let syncTimer: ReturnType<typeof setTimeout> | null = null;
let hashListenerAttached = false;
let sidebarCaptureAttached = false;
let earlyPrepareStarted = false;
let catchupTimer: ReturnType<typeof setInterval> | null = null;
let catchupTimers: ReturnType<typeof setTimeout>[] = [];
let nativeMenuListenerAttached = false;
let lastMenuFingerprint = "";
const settingsListeners: Array<{
key: keyof SettingsState;
listener: ChangeListener;
}> = [];
function syncFromMenu() {
if (menuEl) sidebarState.syncFromNative(menuEl);
/** Stable fingerprint of native menu keys so unchanged DOM does not re-sync. */
function menuFingerprint(menu: HTMLElement): string {
const list = getNativeMenuList(menu);
if (!list) return "";
const keys: string[] = [];
for (const node of list.children) {
const el = node as HTMLElement;
if (el.id === ROOT_ID) continue;
const key = el.dataset.key ?? "";
const path = el.dataset.path ?? "";
const colour = el.dataset.colour ?? "";
keys.push(`${key}|${path}|${colour}`);
}
return `${keys.length}:${keys.join(",")}`;
}
function syncFromMenu(force = false) {
if (!menuEl) return;
const next = menuFingerprint(menuEl);
if (!force && next === lastMenuFingerprint) return;
lastMenuFingerprint = next;
sidebarState.syncFromNative(menuEl);
}
/**
* Sparse catch-up after mount: plugins inject menu items shortly after first paint.
* MutationObserver + scheduleSync cover ongoing changes; this only covers a short window.
*/
function startCatchupSync() {
if (catchupTimer) clearInterval(catchupTimer);
let attempts = 0;
catchupTimer = setInterval(() => {
attempts += 1;
syncFromMenu();
// Plugins (Analytics, Overview, icons) inject shortly after first paint.
if (attempts >= 60) {
clearInterval(catchupTimer!);
catchupTimer = null;
}
}, 50);
for (const t of catchupTimers) clearTimeout(t);
catchupTimers = [];
// Immediate, then a few delayed passes (~1s total) instead of 50ms×60.
const delays = [0, 200, 500, 1000];
for (const ms of delays) {
catchupTimers.push(
setTimeout(() => {
syncFromMenu();
}, ms),
);
}
if (typeof requestIdleCallback === "function") {
requestIdleCallback(() => syncFromMenu(), { timeout: 1500 });
}
}
function onNativeMenuUpdated() {
syncFromMenu(true);
}
function scheduleSync() {
@@ -201,7 +234,12 @@ export async function mountCustomSidebar(): Promise<boolean> {
document.documentElement.classList.add(PENDING_CLASS);
const menu = (await waitForElm("#menu", true, 50, 200)) as HTMLElement | null;
let menu: HTMLElement | null = null;
try {
menu = (await waitForSeqtaMenu(50, 200)) as HTMLElement;
} catch {
return false;
}
if (!menu) return false;
// Prefer a populated native list, but don't block forever during loading.
@@ -270,7 +308,7 @@ export async function mountCustomSidebar(): Promise<boolean> {
}
if (!nativeMenuListenerAttached) {
window.addEventListener("bsplus-native-menu-updated", syncFromMenu);
window.addEventListener("bsplus-native-menu-updated", onNativeMenuUpdated);
nativeMenuListenerAttached = true;
}
@@ -288,7 +326,7 @@ export async function mountCustomSidebar(): Promise<boolean> {
] as const) {
registerSetting(key, () => applySidebarLook(menuEl));
}
const resync = () => syncFromMenu();
const resync = () => syncFromMenu(true);
registerSetting("menuorder", resync);
registerSetting("menuitems", resync);
@@ -302,8 +340,9 @@ export function unmountCustomSidebar() {
menuObserver = null;
if (syncTimer) clearTimeout(syncTimer);
syncTimer = null;
if (catchupTimer) clearInterval(catchupTimer);
catchupTimer = null;
for (const t of catchupTimers) clearTimeout(t);
catchupTimers = [];
lastMenuFingerprint = "";
clearSettingListeners();
@@ -318,7 +357,7 @@ export function unmountCustomSidebar() {
}
if (nativeMenuListenerAttached) {
window.removeEventListener("bsplus-native-menu-updated", syncFromMenu);
window.removeEventListener("bsplus-native-menu-updated", onNativeMenuUpdated);
nativeMenuListenerAttached = false;
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { mount, unmount } from "svelte";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import { waitForElm } from "@/seqta/utils/waitForElm";
import { waitForSeqtaTitle } from "@/seqta/utils/waitForSeqtaShell";
import TitleBar from "./TitleBar.svelte";
import { titleBarState } from "./titleBarState.svelte";
@@ -105,7 +105,7 @@ export async function mountCustomTitleBar(): Promise<boolean> {
let title: HTMLElement;
try {
title = (await waitForElm("#title", true, 50, 200)) as HTMLElement;
title = (await waitForSeqtaTitle(50, 200)) as HTMLElement;
} catch {
return false;
}
@@ -311,10 +311,10 @@ export async function loadEngageHomePage(): Promise<void> {
const engageHomeBody = stringToHTML(/* html */ `
<div class="home-root" id="engage-home-root">
<div class="home-container" id="engage-home-container">
<div class="border shortcut-container">
<div class="border shortcuts" id="shortcuts"></div>
<div class="bsplus-rounded shortcut-container">
<div class="bsplus-rounded shortcuts" id="shortcuts"></div>
</div>
<div class="border timetable-container">
<div class="bsplus-rounded timetable-container">
<div class="home-subtitle">
<div class="engage-timetable-title-cluster">
<h2 id="engage-home-lesson-subtitle">Today's Lessons</h2>
@@ -331,7 +331,7 @@ export async function loadEngageHomePage(): Promise<void> {
</div>
<div class="day-container loading" id="engage-day-container"></div>
</div>
<div class="border notices-container">
<div class="bsplus-rounded notices-container">
<div style="display: flex; justify-content: space-between">
<h2 class="home-subtitle">Notices</h2>
<input type="date" id="engage-notices-date" />
+5 -5
View File
@@ -62,10 +62,10 @@ export async function loadHomePage() {
const skeletonStructure = stringToHTML(/* html */ `
<div class="home-container" id="home-container">
<div class="border shortcut-container">
<div class="border shortcuts" id="shortcuts"></div>
<div class="bsplus-rounded shortcut-container">
<div class="bsplus-rounded shortcuts" id="shortcuts"></div>
</div>
<div class="border timetable-container">
<div class="bsplus-rounded timetable-container">
<div class="home-subtitle">
<h2 id="home-lesson-subtitle">Today's Lessons</h2>
<div class="timetable-arrows">
@@ -80,7 +80,7 @@ export async function loadHomePage() {
<div class="day-container loading" id="day-container">
</div>
</div>
<div class="border upcoming-container">
<div class="bsplus-rounded upcoming-container">
<div class="upcoming-title">
<h2 class="home-subtitle">Upcoming Assessments</h2>
<div class="upcoming-filters" id="upcoming-filters"></div>
@@ -88,7 +88,7 @@ export async function loadHomePage() {
<div class="upcoming-items loading" id="upcoming-items">
</div>
</div>
<div class="border notices-container">
<div class="bsplus-rounded notices-container">
<div style="display: flex; justify-content: space-between">
<h2 class="home-subtitle">Notices</h2>
<input type="date" />
@@ -24,20 +24,58 @@ export interface ThemeOfTheMonthEntry {
updated_at: number;
}
const TOTM_FETCH_TIMEOUT_MS = 800;
const TOTM_CACHE_KEY = "bsplus_theme_of_the_month_cache";
function isValidTotmEntry(data: unknown): data is ThemeOfTheMonthEntry {
return (
!!data &&
typeof data === "object" &&
typeof (data as ThemeOfTheMonthEntry).id === "string" &&
!!(data as ThemeOfTheMonthEntry).id
);
}
async function readCachedThemeOfTheMonth(): Promise<ThemeOfTheMonthEntry | null> {
try {
const stored = await browser.storage.local.get(TOTM_CACHE_KEY);
const cached = stored[TOTM_CACHE_KEY];
return isValidTotmEntry(cached) ? cached : null;
} catch {
return null;
}
}
async function writeCachedThemeOfTheMonth(
entry: ThemeOfTheMonthEntry,
): Promise<void> {
try {
await browser.storage.local.set({ [TOTM_CACHE_KEY]: entry });
} catch {
/* ignore cache write failures */
}
}
/**
* Fetches Theme of the Month with a hard timeout so a hung API cannot stall
* the startup popup queue. Falls back to last-good cached entry on timeout/error.
*/
export async function fetchThemeOfTheMonth(): Promise<ThemeOfTheMonthEntry | null> {
try {
const res = await fetch(`${getApiBase()}/api/theme-of-the-month/current`, {
cache: "no-store",
signal: AbortSignal.timeout(TOTM_FETCH_TIMEOUT_MS),
});
if (!res.ok) return null;
if (!res.ok) return readCachedThemeOfTheMonth();
const text = await res.text();
if (!text) return null;
if (!text) return readCachedThemeOfTheMonth();
const data = JSON.parse(text);
if (!data || typeof data !== "object" || !data.id) return null;
return data as ThemeOfTheMonthEntry;
if (!isValidTotmEntry(data)) return readCachedThemeOfTheMonth();
void writeCachedThemeOfTheMonth(data);
return data;
} catch (err) {
console.warn("[ThemeOfTheMonth] Failed to fetch current entry:", err);
return null;
return readCachedThemeOfTheMonth();
}
}
@@ -0,0 +1,52 @@
import browser from "webextension-polyfill";
import { fetchThemeOfTheMonth } from "./OpenThemeOfTheMonthPopup";
jest.mock("../DevApiBase", () => ({
getApiBase: () => "https://example.test",
}));
const CACHE_KEY = "bsplus_theme_of_the_month_cache";
const sampleEntry = {
id: "totm-1",
month: "2026-07",
title: "July Theme",
description: "desc",
cover_image: null,
theme_id: null,
theme: null,
created_at: 1,
updated_at: 1,
};
describe("fetchThemeOfTheMonth", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
jest.clearAllMocks();
});
it("caches a successful response", async () => {
globalThis.fetch = jest.fn().mockResolvedValue({
ok: true,
text: async () => JSON.stringify(sampleEntry),
}) as typeof fetch;
const entry = await fetchThemeOfTheMonth();
expect(entry?.id).toBe("totm-1");
expect(browser.storage.local.set).toHaveBeenCalledWith({
[CACHE_KEY]: sampleEntry,
});
});
it("falls back to cache when fetch fails or times out", async () => {
await browser.storage.local.set({ [CACHE_KEY]: sampleEntry });
globalThis.fetch = jest
.fn()
.mockRejectedValue(new DOMException("Aborted", "AbortError"));
const entry = await fetchThemeOfTheMonth();
expect(entry?.id).toBe("totm-1");
});
});
+1 -1
View File
@@ -42,7 +42,7 @@ export async function SendNewsPage() {
const html = stringToHTML(/* html */ `
<div class="home-root">
<div class="home-container" id="news-container">
<h1 class="border">Latest Headlines in ${displayCountry}</h1>
<h1 class="bsplus-rounded">Latest Headlines in ${displayCountry}</h1>
</div>
</div>`);
+48
View File
@@ -0,0 +1,48 @@
/**
* @jest-environment jsdom
*/
import { waitForElm } from "@/seqta/utils/waitForElm";
import {
resetSeqtaShellWaiters,
waitForSeqtaMenu,
waitForSeqtaTitle,
} from "./waitForSeqtaShell";
jest.mock("@/seqta/utils/waitForElm", () => ({
waitForElm: jest.fn(),
}));
describe("waitForSeqtaShell", () => {
beforeEach(() => {
resetSeqtaShellWaiters();
document.body.innerHTML = "";
jest.mocked(waitForElm).mockReset();
});
it("returns existing #title without calling waitForElm", async () => {
const el = document.createElement("div");
el.id = "title";
document.body.append(el);
await expect(waitForSeqtaTitle()).resolves.toBe(el);
expect(waitForElm).not.toHaveBeenCalled();
});
it("shares one waitForElm promise for concurrent #menu waiters", async () => {
let resolveWait!: (el: Element) => void;
const pending = new Promise<Element>((resolve) => {
resolveWait = resolve;
});
jest.mocked(waitForElm).mockReturnValue(pending);
const a = waitForSeqtaMenu();
const b = waitForSeqtaMenu();
expect(waitForElm).toHaveBeenCalledTimes(1);
const menu = document.createElement("div");
menu.id = "menu";
resolveWait(menu);
await expect(Promise.all([a, b])).resolves.toEqual([menu, menu]);
});
});
+55
View File
@@ -0,0 +1,55 @@
import { waitForElm } from "@/seqta/utils/waitForElm";
/**
* Shared readiness promises for SEQTA chrome elements that titlebar, search,
* and sidebar all wait on. Avoids overlapping waitForElm observers/polls.
*/
type ShellKey = "title" | "menu";
const inflight = new Map<ShellKey, Promise<Element>>();
function sharedWait(
key: ShellKey,
selector: string,
interval: number,
maxIterations: number,
): Promise<Element> {
const existing = inflight.get(key);
if (existing) return existing;
const promise = waitForElm(selector, true, interval, maxIterations).then(
(el) => el,
(err) => {
inflight.delete(key);
throw err;
},
);
inflight.set(key, promise);
return promise;
}
/** Resolves once `#title` exists (shared across titlebar + global search). */
export function waitForSeqtaTitle(
interval = 50,
maxIterations = 200,
): Promise<Element> {
const immediate = document.querySelector("#title");
if (immediate) return Promise.resolve(immediate);
return sharedWait("title", "#title", interval, maxIterations);
}
/** Resolves once `#menu` exists (shared across sidebar + background layers). */
export function waitForSeqtaMenu(
interval = 50,
maxIterations = 200,
): Promise<Element> {
const immediate = document.querySelector("#menu");
if (immediate) return Promise.resolve(immediate);
return sharedWait("menu", "#menu", interval, maxIterations);
}
/** Test/helpers: clear shared caches between mounts. */
export function resetSeqtaShellWaiters(): void {
inflight.clear();
}