From a49d5d352aa65bbd3a6f66ef64169811bf6e1ab1 Mon Sep 17 00:00:00 2001 From: Dirk Date: Mon, 31 Aug 2026 10:35:33 +0200 Subject: [PATCH 1/3] feat(app): implement boot splash and timeout handling for improved user experience --- app/src/main.ts | 18 ++- app/vite-plugins/bootSplash.ts | 133 ++++++++++++++++++ app/vite.config.ts | 2 + .../app/flows/boot-recovery.spec.ts | 94 +++++++++++++ shared/src/db/database.ts | 74 ++++++---- shared/src/db/dbOpenFailure.spec.ts | 75 ++++++++++ 6 files changed, 369 insertions(+), 27 deletions(-) create mode 100644 app/vite-plugins/bootSplash.ts create mode 100644 playwright-tests/app/flows/boot-recovery.spec.ts create mode 100644 shared/src/db/dbOpenFailure.spec.ts diff --git a/app/src/main.ts b/app/src/main.ts index 80137642ac..601ed5bc7f 100644 --- a/app/src/main.ts +++ b/app/src/main.ts @@ -32,6 +32,19 @@ app.use(createPinia()); initSentry(app); +/** + * Backstop for a startup that stalls instead of failing. Nothing below rejects on a + * promise that simply never settles, so without this the user sits on the boot splash + * and nothing is reported. Comfortably above the ~30s worst case of a slow silent + * token refresh so a slow-but-working boot is not flagged. + */ +const BOOT_TIMEOUT_MS = 45_000; + +const bootWatchdog = setTimeout(() => { + markAppError(); + Sentry?.captureMessage(`App boot did not complete within ${BOOT_TIMEOUT_MS}ms`, "error"); +}, BOOT_TIMEOUT_MS); + /** * Content sync window. Installed (standalone) sessions sync the full corpus (no * cutoff). Browser-tab sessions sync only the last ~1 month; content with @@ -62,9 +75,6 @@ async function Startup() { contentPublishDateCutoff: installedStandalone ? undefined // no cutoff → full corpus : Date.now() - BROWSER_CONTENT_SYNC_WINDOW_MS, - }).catch((err) => { - console.error(err); - Sentry?.captureException(err); }); // Keep the CMS-managed default-affinity baseline/config in sync with the local @@ -132,10 +142,12 @@ async function Startup() { isAppLoading.value = false; initAppTitle(i18n); initAnalytics(); + clearTimeout(bootWatchdog); markAppReady(); } Startup().catch((err) => { + clearTimeout(bootWatchdog); console.error(err); Sentry?.captureException(err); markAppError(); diff --git a/app/vite-plugins/bootSplash.ts b/app/vite-plugins/bootSplash.ts new file mode 100644 index 0000000000..fda3c33218 --- /dev/null +++ b/app/vite-plugins/bootSplash.ts @@ -0,0 +1,133 @@ +import type { Plugin } from "vite"; + +/** + * Static boot splash for the SPA build. The app mounts only after the data layer and + * auth have initialised, and the Vue splash lives inside App.vue — so without this + * everything before mount paints an empty `#app`. Injected inside `#app` so Vue's + * mount clears it; no teardown code to keep in step. + * + * Not wired into the web build (vite.config.web.ts), whose `#app` carries prerendered + * content that must not be covered. + */ +const SPLASH_STYLE = ` +#boot-splash { + --boot-splash-bg: #ffffff; + --boot-splash-fg: #d4d4d8; + --boot-splash-track: #f4f4f5; + --boot-splash-slug: #a1a1aa; + position: fixed; + inset: 0; + z-index: 60; + display: flex; + align-items: center; + justify-content: center; + background: var(--boot-splash-bg); + font-family: ui-sans-serif, system-ui, sans-serif; +} +@media (prefers-color-scheme: dark) { + #boot-splash { + --boot-splash-bg: #0f172a; + --boot-splash-fg: #71717a; + --boot-splash-track: #d4d4d8; + --boot-splash-slug: #71717a; + } +} +html.dark #boot-splash { + --boot-splash-bg: #0f172a; + --boot-splash-fg: #71717a; + --boot-splash-track: #d4d4d8; + --boot-splash-slug: #71717a; +} +#boot-splash .boot-splash-panel { + display: flex; + width: 100%; + flex-direction: column; + align-items: center; + gap: 1rem; + padding: 0 1rem; +} +#boot-splash .boot-splash-label { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: var(--boot-splash-fg); + text-align: center; +} +#boot-splash .boot-splash-track { + position: relative; + height: 0.75rem; + width: 80%; + max-width: 28rem; + overflow: hidden; + border-radius: 9999px; + background: var(--boot-splash-track); +} +#boot-splash .boot-splash-slug { + position: absolute; + top: 0; + bottom: 0; + width: 40%; + border-radius: 9999px; + background: var(--boot-splash-slug); + animation: boot-splash-slug 1.2s linear infinite; +} +@keyframes boot-splash-slug { + 0% { left: -40%; } + 100% { left: 100%; } +} +@media (prefers-reduced-motion: reduce) { + #boot-splash .boot-splash-slug { animation: none; left: 30%; } +} +#boot-splash .boot-splash-error { display: none; } +#boot-splash button { + border-radius: 0.375rem; + border: 1px solid var(--boot-splash-slug); + background: transparent; + padding: 0.5rem 1.25rem; + font-size: 1rem; + color: var(--boot-splash-fg); + cursor: pointer; +} +html[data-render-state="error"] #boot-splash .boot-splash-loading { display: none; } +html[data-render-state="error"] #boot-splash .boot-splash-error { display: flex; } +`; + +const SPLASH_MARKUP = `
+
+

Loading...

+
+
+
+

The app could not be started.

+ +
+
`; + +// `?nosplash` is the existing opt-out honoured by `isAppLoading`; mirror it here so the +// query string suppresses the whole splash, not just the Vue half. +const SPLASH_SCRIPT = `(function () { + var splash = document.getElementById("boot-splash"); + if (!splash) return; + if (new URLSearchParams(window.location.search).has("nosplash")) { + splash.remove(); + return; + } + var reload = document.getElementById("boot-splash-reload"); + if (reload) reload.addEventListener("click", function () { window.location.reload(); }); +})();`; + +export function bootSplash(): Plugin { + return { + name: "boot-splash", + transformIndexHtml: { + order: "pre", + handler: (html) => ({ + html: html.replace('
', `
${SPLASH_MARKUP}
`), + tags: [ + { tag: "style", injectTo: "head" as const, children: SPLASH_STYLE }, + { tag: "script", injectTo: "body" as const, children: SPLASH_SCRIPT }, + ], + }), + }, + }; +} diff --git a/app/vite.config.ts b/app/vite.config.ts index 0532699592..1e97c36def 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -7,6 +7,7 @@ import { VitePWA } from "vite-plugin-pwa"; // @ts-expect-error - JavaScript module without type declarations import movePreloadScriptsToBody from "./src/assets/vite-plugins/movePreloadScriptsToBody.js"; import { buildTargetVirtuals } from "./vite-plugins/buildTargetVirtuals"; +import { bootSplash } from "./vite-plugins/bootSplash"; const env = loadEnv("", process.cwd()); @@ -55,6 +56,7 @@ export default defineConfig({ plugins: [ blockGeneratedOutput(), buildTargetVirtuals(), + bootSplash(), visualizer({ open: false }), // Open visualiser when reviewing build bundle size vue(), viteStaticCopy({ diff --git a/playwright-tests/app/flows/boot-recovery.spec.ts b/playwright-tests/app/flows/boot-recovery.spec.ts new file mode 100644 index 0000000000..461bebe13d --- /dev/null +++ b/playwright-tests/app/flows/boot-recovery.spec.ts @@ -0,0 +1,94 @@ +import type { Page } from "@playwright/test"; +import { appTest as test, expect } from "../../fixtures/test"; + +/** + * The app mounts only after the database, sync and auth have initialised, and the Vue + * splash lives inside `App.vue` — so everything before mount paints an empty `#app` + * unless the build injects a static splash into it. A boot that stalls in that window + * leaves the user on a blank page with nothing reported. + */ + +/** + * Replaces the IndexedDB open the boot path starts with. `blocked` is what a browser + * reports when another connection still holds the database; `silent` never answers at + * all, which is the case no event can rescue. + */ +async function breakIndexedDbOpen(page: Page, mode: "blocked" | "silent") { + await page.addInitScript((stubMode) => { + Object.defineProperty(window.indexedDB, "open", { + configurable: true, + value: () => { + const request: Record = {}; + if (stubMode === "blocked") { + setTimeout(() => { + (request.onblocked as (() => void) | undefined)?.(); + }, 0); + } + return request; + }, + }); + }, mode); +} + +test.describe("App boot recovery", () => { + test("serves the boot splash inside #app", async ({ page }) => { + // Asserted against the raw HTML because the splash is injected by a string + // replace on `
`, which no-ops silently if that markup + // ever changes — leaving the pre-mount window blank again. + const html = await (await page.request.get("/")).text(); + + expect(html).toMatch(/
\s*
{ + await page.goto("/"); + + await expect(page.getByRole("main")).toBeVisible(); + await expect(page.locator("#boot-splash")).toHaveCount(0); + }); + + test.describe("before scripts run", () => { + // Approximates the pre-mount window: the splash has to stand on its own markup + // and CSS, with no help from the bundle. + test.use({ javaScriptEnabled: false }); + + test("covers the viewport", async ({ page }) => { + await page.goto("/"); + + await expect(page.locator("#boot-splash")).toBeVisible(); + await expect(page.locator("#boot-splash")).toContainText("Loading"); + }); + }); + + test("reports an error when the database open is blocked", async ({ + page, + }) => { + await breakIndexedDbOpen(page, "blocked"); + await page.goto("/"); + + await expect(page.locator("#boot-splash .boot-splash-error")).toBeVisible({ + timeout: 20_000, + }); + await expect(page.getByRole("button", { name: "Reload" })).toBeVisible(); + await expect(page.locator("html")).toHaveAttribute( + "data-render-state", + "error", + ); + }); + + test("reports an error when the database open never answers", async ({ + page, + }) => { + await breakIndexedDbOpen(page, "silent"); + await page.goto("/"); + + // Bounded by the boot-path open timeout rather than waiting forever. + await expect(page.locator("#boot-splash .boot-splash-error")).toBeVisible({ + timeout: 30_000, + }); + await expect(page.locator("html")).toHaveAttribute( + "data-render-state", + "error", + ); + }); +}); diff --git a/shared/src/db/database.ts b/shared/src/db/database.ts index 5d9567f96d..5ec67ff5c6 100644 --- a/shared/src/db/database.ts +++ b/shared/src/db/database.ts @@ -874,30 +874,47 @@ class Database extends Dexie { export let db: Database; +/** + * IndexedDB gives no answer at all when an open is blocked by another connection, so + * every open in the boot path is bounded. + */ +const DB_OPEN_TIMEOUT_MS = 10_000; + +function withDbTimeout(promise: Promise, action: string): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error(`Timed out trying to ${action}`)), + DB_OPEN_TIMEOUT_MS, + ); + promise.then( + (value) => { + clearTimeout(timeout); + resolve(value); + }, + (error) => { + clearTimeout(timeout); + reject(error); + }, + ); + }); +} + export async function initDatabase() { const _v: number = await getDbVersion(); db = new Database(_v, config.docsIndex); - // Open the database and wait for it to be ready - await new Promise((resolve) => { - if (db.isOpen()) { - resolve(); - return; - } - - db.on("ready", () => { - resolve(); - }); - - if (!db.isOpen()) { - db.open(); - } - }); - + // Registered before open(): a blocked upgrade is what this reports, and it is + // raised during the open, not after it. db.on("blocked", () => { console.error("Database blocked"); }); + // Awaiting open() rather than the "ready" event means a rejected open is raised + // as a boot error instead of leaving this promise unsettled. + if (!db.isOpen()) { + await withDbTimeout(db.open(), "open the database"); + } + // Compute FTS corpus stats on startup. // Uses setTimeout(0) to avoid Dexie PSD zone deadlocks during initialization. setTimeout(() => { @@ -979,13 +996,21 @@ async function resetGroupSyncListForRecovery() { } /** - * Get IndexDB version before DB class is initialized + * Get IndexDB version before DB class is initialized. Rejects rather than staying + * unsettled when the open is blocked or fails, because this runs before the app is + * mounted and an unsettled promise here strands the user on the boot screen. * @returns IndexDB Version */ -export const getDbVersion = async () => { - const request = indexedDB.open(dbName); - return new Promise((resolve) => { +export const getDbVersion = () => + new Promise((resolve, reject) => { + const request = indexedDB.open(dbName); + const timeout = setTimeout( + () => reject(new Error("Timed out reading the database version")), + DB_OPEN_TIMEOUT_MS, + ); + request.onsuccess = (event: any) => { + clearTimeout(timeout); const db = event.target.result; const version: number = (db && db.version) || 0; db.addEventListener("close", () => {}); @@ -993,13 +1018,14 @@ export const getDbVersion = async () => { resolve(version); }; request.onblocked = () => { - console.error("Database blocked"); + clearTimeout(timeout); + reject(new Error("Database blocked while reading the database version")); }; request.onerror = () => { - console.error("Database error"); + clearTimeout(timeout); + reject(new Error("Database error while reading the database version")); }; - }) as unknown as Promise; -}; + }); /** * Concatenate Shared Library index with the external index, to avoid having duplicate indexes diff --git a/shared/src/db/dbOpenFailure.spec.ts b/shared/src/db/dbOpenFailure.spec.ts new file mode 100644 index 0000000000..c859f15727 --- /dev/null +++ b/shared/src/db/dbOpenFailure.spec.ts @@ -0,0 +1,75 @@ +import "fake-indexeddb/auto"; +import { describe, it, expect, afterEach, vi } from "vitest"; +import Dexie from "dexie"; +import { getDbVersion, initDatabase } from "./database"; +import { initConfig } from "../config"; + +// The boot-path database opens run before the app is mounted, so a promise that never +// settles here strands the user on the boot splash with nothing reported. +const originalIndexedDb = globalThis.indexedDB; + +function stubOpen(fire: "onsuccess" | "onblocked" | "onerror" | "never") { + const request: Record void) | undefined> = {}; + (globalThis as any).indexedDB = { + open: () => { + if (fire !== "never") { + setTimeout(() => { + if (fire === "onsuccess") { + request.onsuccess?.({ + target: { result: { version: 70, addEventListener() {}, close() {} } }, + }); + } else { + request[fire]?.(); + } + }, 0); + } + return request; + }, + }; +} + +describe("getDbVersion", () => { + afterEach(() => { + vi.useRealTimers(); + (globalThis as any).indexedDB = originalIndexedDb; + }); + + it("resolves the version when the open succeeds", async () => { + stubOpen("onsuccess"); + await expect(getDbVersion()).resolves.toBe(70); + }); + + it("rejects when the open is blocked by another connection", async () => { + stubOpen("onblocked"); + await expect(getDbVersion()).rejects.toThrow(/blocked/i); + }); + + it("rejects when the open errors", async () => { + stubOpen("onerror"); + await expect(getDbVersion()).rejects.toThrow(/error/i); + }); + + it("rejects when the open never answers at all", async () => { + vi.useFakeTimers(); + stubOpen("never"); + + const assertion = expect(getDbVersion()).rejects.toThrow(/timed out/i); + await vi.advanceTimersByTimeAsync(10_000); + await assertion; + }); +}); + +describe("initDatabase", () => { + afterEach(() => vi.restoreAllMocks()); + + it("rejects when the database open fails", async () => { + initConfig({ + cms: false, + docsIndex: "[type+postType]", + apiUrl: "http://localhost:12345", + }); + vi.spyOn(Dexie.prototype, "open").mockRejectedValue(new Error("VersionError")); + + await expect(initDatabase()).rejects.toThrow("VersionError"); + }); +}); From 69eb7b709edfdbbf56e85a78770cde61d8ede168 Mon Sep 17 00:00:00 2001 From: Dirk Date: Mon, 31 Aug 2026 12:58:21 +0200 Subject: [PATCH 2/3] feat(language): implement timeout handling in initLanguage to prevent splash screen stalling --- app/src/globalConfig.ts | 21 ++++++++- app/src/initLanguage.spec.ts | 44 +++++++++++++++++++ .../app/flows/boot-recovery.spec.ts | 17 +++++++ 3 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 app/src/initLanguage.spec.ts diff --git a/app/src/globalConfig.ts b/app/src/globalConfig.ts index b688273c00..252d49ac4d 100644 --- a/app/src/globalConfig.ts +++ b/app/src/globalConfig.ts @@ -274,8 +274,27 @@ export const appLanguageAsRef = computed(() => appLanguagesPreferredAsRef.value[ /** * Initialize the language settings. If no user preferred language is set, the browser preferred language is used if it is supported. Otherwise, the CMS default language is used. */ +/** + * Language docs arrive only through sync, which is gated on the socket connecting, so a + * client that starts offline with an empty database would otherwise wait here forever — + * stranding the splash, sync startup and analytics behind it. + */ +const LANGUAGE_BOOT_TIMEOUT_MS = 5_000; + export const initLanguage = () => { return new Promise((resolve) => { + let settled = false; + const settle = () => { + if (settled) return; + settled = true; + clearTimeout(bootTimeout); + resolve(); + }; + + // Let boot continue without languages. The watcher below is deliberately left + // live in that case, so a later sync still normalizes the preferred/synced sets. + const bootTimeout = setTimeout(settle, LANGUAGE_BOOT_TIMEOUT_MS); + // Language is a fully-synced type, so this HybridQuery reads from IndexedDB // only. Constructed at app scope (never disposed) — its output ref feeds the // shared cmsLanguages list. @@ -340,7 +359,7 @@ export const initLanguage = () => { ); unwatchCmsLanguages(); - resolve(); + settle(); }, { deep: true }, ); diff --git a/app/src/initLanguage.spec.ts b/app/src/initLanguage.spec.ts new file mode 100644 index 0000000000..b0bcf9562d --- /dev/null +++ b/app/src/initLanguage.spec.ts @@ -0,0 +1,44 @@ +import "fake-indexeddb/auto"; +import { describe, it, expect, afterEach } from "vitest"; +import { db } from "luminary-shared"; +import { appLanguageIdsAsRef, initLanguage } from "@/globalConfig"; +import { mockLanguageDtoEng } from "./tests/mockdata"; +import waitForExpect from "wait-for-expect"; + +/** + * `initLanguage()` is awaited in main.ts between `app.mount()` and + * `isAppLoading.value = false`, so a promise that never settles here leaves the app on + * the splash with sync, analytics and `markAppReady()` all stranded behind it. Language + * docs arrive only through sync, which is gated on the socket, so an offline cold start + * has no way to satisfy it. + */ + +const settledWithin = (promise: Promise, ms: number) => + Promise.race([ + promise.then(() => "settled" as const), + new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), ms)), + ]); + +describe("initLanguage", () => { + afterEach(async () => { + await db.docs.clear(); + }); + + it("resolves without Language docs so an offline cold start still boots", async () => { + await db.docs.clear(); + + expect(await settledWithin(initLanguage(), 15_000)).toBe("settled"); + }); + + it("still normalizes the preferred languages once a later sync delivers them", async () => { + await db.docs.clear(); + + await initLanguage(); + await db.docs.bulkPut([mockLanguageDtoEng]); + + // The watcher is left live when boot continued without languages. + await waitForExpect(() => { + expect(appLanguageIdsAsRef.value).toContain(mockLanguageDtoEng._id); + }); + }); +}); diff --git a/playwright-tests/app/flows/boot-recovery.spec.ts b/playwright-tests/app/flows/boot-recovery.spec.ts index 461bebe13d..680d022f52 100644 --- a/playwright-tests/app/flows/boot-recovery.spec.ts +++ b/playwright-tests/app/flows/boot-recovery.spec.ts @@ -76,6 +76,23 @@ test.describe("App boot recovery", () => { ); }); + test("does not strand the splash when the socket never connects", async ({ + page, + }) => { + // Only the socket is blocked — REST stays reachable. Language docs are a + // fully-synced type whose sync is gated on `isConnected`, so a client that + // cannot open a socket never receives them, and `initLanguage()` is awaited + // between mount and the splash being cleared. + await page.route("**/socket.io/**", (route) => route.abort()); + await page.goto("/"); + + // Mounted: the static splash is gone, so the boot path got past app.mount(). + await expect(page.locator("#boot-splash")).toHaveCount(0); + + // The app must still become usable offline rather than sitting on the splash. + await expect(page.getByRole("main")).toBeVisible({ timeout: 30_000 }); + }); + test("reports an error when the database open never answers", async ({ page, }) => { From d7be7c830d16d379f6e87d3202a33f19a3dc1bce Mon Sep 17 00:00:00 2001 From: Christian Touoyim Date: Wed, 2 Sep 2026 08:54:00 +0100 Subject: [PATCH 3/3] refactor: hand the boot splash over to #1974 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both this branch and #1974 add a static boot splash, so only one can land. #1974 keeps the splash and has absorbed this branch's error panel, ?nosplash guard and reduced-motion handling; this branch keeps the recovery work the splash reports through — the bounded database opens, the initLanguage timeout and the boot watchdog. The placement assertion goes with it: #1974 renders the splash as a sibling after #app rather than inside it, so that test can no longer hold here. The remaining cases assert the ids, classes and data-render-state contract #1974 preserved, and need its splash deployed to run green. Co-Authored-By: Claude Opus 5 --- app/vite-plugins/bootSplash.ts | 133 ------------------ app/vite.config.ts | 2 - .../app/flows/boot-recovery.spec.ts | 17 +-- 3 files changed, 4 insertions(+), 148 deletions(-) delete mode 100644 app/vite-plugins/bootSplash.ts diff --git a/app/vite-plugins/bootSplash.ts b/app/vite-plugins/bootSplash.ts deleted file mode 100644 index fda3c33218..0000000000 --- a/app/vite-plugins/bootSplash.ts +++ /dev/null @@ -1,133 +0,0 @@ -import type { Plugin } from "vite"; - -/** - * Static boot splash for the SPA build. The app mounts only after the data layer and - * auth have initialised, and the Vue splash lives inside App.vue — so without this - * everything before mount paints an empty `#app`. Injected inside `#app` so Vue's - * mount clears it; no teardown code to keep in step. - * - * Not wired into the web build (vite.config.web.ts), whose `#app` carries prerendered - * content that must not be covered. - */ -const SPLASH_STYLE = ` -#boot-splash { - --boot-splash-bg: #ffffff; - --boot-splash-fg: #d4d4d8; - --boot-splash-track: #f4f4f5; - --boot-splash-slug: #a1a1aa; - position: fixed; - inset: 0; - z-index: 60; - display: flex; - align-items: center; - justify-content: center; - background: var(--boot-splash-bg); - font-family: ui-sans-serif, system-ui, sans-serif; -} -@media (prefers-color-scheme: dark) { - #boot-splash { - --boot-splash-bg: #0f172a; - --boot-splash-fg: #71717a; - --boot-splash-track: #d4d4d8; - --boot-splash-slug: #71717a; - } -} -html.dark #boot-splash { - --boot-splash-bg: #0f172a; - --boot-splash-fg: #71717a; - --boot-splash-track: #d4d4d8; - --boot-splash-slug: #71717a; -} -#boot-splash .boot-splash-panel { - display: flex; - width: 100%; - flex-direction: column; - align-items: center; - gap: 1rem; - padding: 0 1rem; -} -#boot-splash .boot-splash-label { - margin: 0; - font-size: 1.25rem; - font-weight: 600; - color: var(--boot-splash-fg); - text-align: center; -} -#boot-splash .boot-splash-track { - position: relative; - height: 0.75rem; - width: 80%; - max-width: 28rem; - overflow: hidden; - border-radius: 9999px; - background: var(--boot-splash-track); -} -#boot-splash .boot-splash-slug { - position: absolute; - top: 0; - bottom: 0; - width: 40%; - border-radius: 9999px; - background: var(--boot-splash-slug); - animation: boot-splash-slug 1.2s linear infinite; -} -@keyframes boot-splash-slug { - 0% { left: -40%; } - 100% { left: 100%; } -} -@media (prefers-reduced-motion: reduce) { - #boot-splash .boot-splash-slug { animation: none; left: 30%; } -} -#boot-splash .boot-splash-error { display: none; } -#boot-splash button { - border-radius: 0.375rem; - border: 1px solid var(--boot-splash-slug); - background: transparent; - padding: 0.5rem 1.25rem; - font-size: 1rem; - color: var(--boot-splash-fg); - cursor: pointer; -} -html[data-render-state="error"] #boot-splash .boot-splash-loading { display: none; } -html[data-render-state="error"] #boot-splash .boot-splash-error { display: flex; } -`; - -const SPLASH_MARKUP = `
-
-

Loading...

-
-
-
-

The app could not be started.

- -
-
`; - -// `?nosplash` is the existing opt-out honoured by `isAppLoading`; mirror it here so the -// query string suppresses the whole splash, not just the Vue half. -const SPLASH_SCRIPT = `(function () { - var splash = document.getElementById("boot-splash"); - if (!splash) return; - if (new URLSearchParams(window.location.search).has("nosplash")) { - splash.remove(); - return; - } - var reload = document.getElementById("boot-splash-reload"); - if (reload) reload.addEventListener("click", function () { window.location.reload(); }); -})();`; - -export function bootSplash(): Plugin { - return { - name: "boot-splash", - transformIndexHtml: { - order: "pre", - handler: (html) => ({ - html: html.replace('
', `
${SPLASH_MARKUP}
`), - tags: [ - { tag: "style", injectTo: "head" as const, children: SPLASH_STYLE }, - { tag: "script", injectTo: "body" as const, children: SPLASH_SCRIPT }, - ], - }), - }, - }; -} diff --git a/app/vite.config.ts b/app/vite.config.ts index 1e97c36def..0532699592 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -7,7 +7,6 @@ import { VitePWA } from "vite-plugin-pwa"; // @ts-expect-error - JavaScript module without type declarations import movePreloadScriptsToBody from "./src/assets/vite-plugins/movePreloadScriptsToBody.js"; import { buildTargetVirtuals } from "./vite-plugins/buildTargetVirtuals"; -import { bootSplash } from "./vite-plugins/bootSplash"; const env = loadEnv("", process.cwd()); @@ -56,7 +55,6 @@ export default defineConfig({ plugins: [ blockGeneratedOutput(), buildTargetVirtuals(), - bootSplash(), visualizer({ open: false }), // Open visualiser when reviewing build bundle size vue(), viteStaticCopy({ diff --git a/playwright-tests/app/flows/boot-recovery.spec.ts b/playwright-tests/app/flows/boot-recovery.spec.ts index 680d022f52..47644ba4fa 100644 --- a/playwright-tests/app/flows/boot-recovery.spec.ts +++ b/playwright-tests/app/flows/boot-recovery.spec.ts @@ -2,10 +2,10 @@ import type { Page } from "@playwright/test"; import { appTest as test, expect } from "../../fixtures/test"; /** - * The app mounts only after the database, sync and auth have initialised, and the Vue - * splash lives inside `App.vue` — so everything before mount paints an empty `#app` - * unless the build injects a static splash into it. A boot that stalls in that window - * leaves the user on a blank page with nothing reported. + * The app mounts only after the database, sync and auth have initialised, so a boot that + * stalls in that window leaves the user with nothing reported. These cover the recovery + * paths — bounded database opens, and a language wait that gives up — through the boot + * splash the build injects, so they need that splash deployed to run green. */ /** @@ -31,15 +31,6 @@ async function breakIndexedDbOpen(page: Page, mode: "blocked" | "silent") { } test.describe("App boot recovery", () => { - test("serves the boot splash inside #app", async ({ page }) => { - // Asserted against the raw HTML because the splash is injected by a string - // replace on `
`, which no-ops silently if that markup - // ever changes — leaving the pre-mount window blank again. - const html = await (await page.request.get("/")).text(); - - expect(html).toMatch(/
\s*
{ await page.goto("/");