diff --git a/apps/host/src/main.ts b/apps/host/src/main.ts index a6f798b8..e3ebaed4 100644 --- a/apps/host/src/main.ts +++ b/apps/host/src/main.ts @@ -34,7 +34,6 @@ import { advancePhase, stopStatusTick, listenForSandboxStatus, - showGatewayEscape, } from "@dotli/ui/ui"; import type { LoadingPhase } from "@dotli/ui/ui"; import { initTopBar, wipeOriginState } from "@dotli/ui/topbar"; @@ -1257,48 +1256,36 @@ async function main(): Promise { log.warn( `[dot.li resolve] path=smoldot (trustless light-client) (${elapsed(T0)})`, ); - // After 10s of slow loading on the verified path, surface a one-click - // escape to the gateway backend. The user trades the light-client - // verification badge for a faster, trust-based load. - const cancelGatewayEscape = showGatewayEscape(() => { - m.count(S.GATEWAY_ESCAPE, { from_backend: chainBackend }); - switchBackendAndReload("rpc-gateway"); - }); - - try { - const { statusToPhase } = await import("@dotli/resolver/resolve"); - const onResolveProgress = (msg: string): void => { - // Progress events arrive as opaque strings across the iframe - // boundary. The resolver package owns the authoritative - // mapping from status text to ResolvePhase, so we defer to it - // instead of maintaining a parallel regex here. - const phase = statusToPhase(msg); - if (phase === "relay-chain-adding") { - advancePhase(1); - } else if ( - // `asset-hub-connecting` is ~0ms (just createClient), so it shares - // the Syncing band rather than getting a slice that makes the bar - // jump for no work. - phase === "asset-hub-connecting" || - phase === "asset-hub-syncing" || - phase === "asset-hub-ready" - ) { - advancePhase(2); - } else if (phase === "resolving-content") { - advancePhase(3); - } - emitPhase(msg, phase ?? "progress"); - trackStatus(msg); - }; - cid = await resolveDotNameRemote(`app.${label}`, onResolveProgress); - if (cid === null) { - cid = await resolveDotNameRemote(label, onResolveProgress); - log.warn( - `[dot.li resolve] fallback ${withActiveTld(label)} contenthash -> ${cid ?? "null"}`, - ); + const { statusToPhase } = await import("@dotli/resolver/resolve"); + const onResolveProgress = (msg: string): void => { + // Progress events arrive as opaque strings across the iframe + // boundary. The resolver package owns the authoritative + // mapping from status text to ResolvePhase, so we defer to it + // instead of maintaining a parallel regex here. + const phase = statusToPhase(msg); + if (phase === "relay-chain-adding") { + advancePhase(1); + } else if ( + // `asset-hub-connecting` is ~0ms (just createClient), so it shares + // the Syncing band rather than getting a slice that makes the bar + // jump for no work. + phase === "asset-hub-connecting" || + phase === "asset-hub-syncing" || + phase === "asset-hub-ready" + ) { + advancePhase(2); + } else if (phase === "resolving-content") { + advancePhase(3); } - } finally { - cancelGatewayEscape(); + emitPhase(msg, phase ?? "progress"); + trackStatus(msg); + }; + cid = await resolveDotNameRemote(`app.${label}`, onResolveProgress); + if (cid === null) { + cid = await resolveDotNameRemote(label, onResolveProgress); + log.warn( + `[dot.li resolve] fallback ${withActiveTld(label)} contenthash -> ${cid ?? "null"}`, + ); } } else { log.warn( diff --git a/apps/host/tests/functional/loading.spec.ts b/apps/host/tests/functional/loading.spec.ts index 02c27557..940113b2 100644 --- a/apps/host/tests/functional/loading.spec.ts +++ b/apps/host/tests/functional/loading.spec.ts @@ -133,30 +133,6 @@ const successfulResolveResponse = (cid: string): string => ` }); `; -/** Rescale any setTimeout call whose delay matches `fromMs` down to `toMs`. */ -async function shrinkTimeout( - page: Page, - fromMs: number, - toMs: number, -): Promise { - await page.addInitScript( - ([from, to]) => { - const orig = window.setTimeout.bind(window); - window.setTimeout = (( - handler: TimerHandler, - ms?: number, - ...rest: unknown[] - ) => - orig( - handler, - ms === from ? to : ms, - ...rest, - )) as typeof window.setTimeout; - }, - [fromMs, toMs], - ); -} - test("As a user using smoldot directly, when the light client panics mid-resolution, I see the appropriate error and can switch backend", async ({ page, }) => { @@ -267,12 +243,11 @@ test("As a user using smoldot in shared worker, when the worker dies silently, I ); }); -test("As a user using smoldot directly, when loading is slow (>10s) I see a one-click gateway escape, and if it times out (>45s) I see the appropriate error and can switch backend", async ({ +test("As a user using smoldot directly, when the sync times out (>45s) I see the appropriate error and can switch backend", async ({ page, }) => { // Given await setBackend(page, "smoldot-direct"); - await shrinkTimeout(page, 10_000, 500); await mockProtocolIframe( page, errorResolveResponse( @@ -285,10 +260,6 @@ test("As a user using smoldot directly, when loading is slow (>10s) I see a one- await page.goto(HOST_URL, { waitUntil: "domcontentloaded" }); // Then - await expect(page.locator(".loading-gateway-btn")).toContainText( - "Use Trusted Provider", - { timeout: 5_000 }, - ); await expect(page.locator(".error-page-title")).toHaveText( "Domain can't be reached", { timeout: 10_000 }, @@ -304,12 +275,11 @@ test("As a user using smoldot directly, when loading is slow (>10s) I see a one- ); }); -test("As a user using smoldot in shared worker, when loading is slow (>10s) I see a one-click gateway escape, and if it times out (>45s) I see the appropriate error and can switch backend", async ({ +test("As a user using smoldot in shared worker, when the sync times out (>45s) I see the appropriate error and can switch backend", async ({ page, }) => { // Given await setBackend(page, "smoldot-shared-worker"); - await shrinkTimeout(page, 10_000, 500); await mockProtocolIframe( page, errorResolveResponse( @@ -322,10 +292,6 @@ test("As a user using smoldot in shared worker, when loading is slow (>10s) I se await page.goto(HOST_URL, { waitUntil: "domcontentloaded" }); // Then - await expect(page.locator(".loading-gateway-btn")).toContainText( - "Use Trusted Provider", - { timeout: 5_000 }, - ); await expect(page.locator(".error-page-title")).toHaveText( "Domain can't be reached", { timeout: 10_000 }, @@ -341,35 +307,6 @@ test("As a user using smoldot in shared worker, when loading is slow (>10s) I se ); }); -test("As a user using smoldot directly, when I click the gateway escape, the backend flips to rpc-gateway and the page reloads", async ({ - page, -}) => { - // Given - await setBackend(page, "smoldot-direct"); - await shrinkTimeout(page, 10_000, 500); - await mockProtocolIframe( - page, - errorResolveResponse("never resolves in test window", 30_000), - ); - - // When - await page.goto(HOST_URL, { waitUntil: "domcontentloaded" }); - const gatewayBtn = page.locator(".loading-gateway-btn"); - await expect(gatewayBtn).toContainText("Use Trusted Provider", { - timeout: 5_000, - }); - await Promise.all([ - page.waitForLoadState("domcontentloaded"), - gatewayBtn.click(), - ]); - - // Then - const backend = await page.evaluate(() => - localStorage.getItem("dotli:chain-backend"), - ); - expect(backend).toBe("rpc-gateway"); -}); - test("As a user, when the app chunks fail to load mid-session, I see the appropriate error with a reload button", async ({ page, }) => { diff --git a/bun.lock b/bun.lock index 95a58e99..73f4c28a 100644 --- a/bun.lock +++ b/bun.lock @@ -323,6 +323,7 @@ "overrides": { "@parity/truapi": "0.9.0", "brace-expansion": "^5.0.9", + "deepmerge-ts": "^8.0.1", "esbuild": "^0.28.1", "fast-uri": "3.1.5", "nanoid": "^3.3.18", @@ -1026,7 +1027,7 @@ "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], - "deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="], + "deepmerge-ts": ["deepmerge-ts@8.0.1", "", {}, "sha512-szCXE7YLCvLKR9bFPJcvsezOShdalctSvrgN/LM/QGUEPZQajwjmsMObZ6/DuANT5lxzM/wtO8Feubwdkz8myA=="], "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], diff --git a/package.json b/package.json index edf8a585..be58879f 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "esbuild": "^0.28.1", "brace-expansion": "^5.0.9", "postcss": "^8.5.24", - "nanoid": "^3.3.18" + "nanoid": "^3.3.18", + "deepmerge-ts": "^8.0.1" } } diff --git a/packages/metrics/src/spans.ts b/packages/metrics/src/spans.ts index e0c668d2..0851c7ba 100644 --- a/packages/metrics/src/spans.ts +++ b/packages/metrics/src/spans.ts @@ -158,13 +158,6 @@ export const AUTH_SESSION_RESTORE = "auth.session_restore"; /** WASM module load time (captured via PerformanceObserver) */ export const WASM_LOAD = "wasm.load"; -/** - * User clicked the "Use gateway instead" escape hatch on the loading - * screen. Tagged with `from_backend` so we can see which verified path - * (smoldot-direct vs smoldot-shared-worker) the user bailed out of. - */ -export const GATEWAY_ESCAPE = "loading.gateway_escape"; - /** * Shared-storage request rejected before it could touch `localStorage`: * bad siteId, malformed key, disallowed origin, or unrecognised value diff --git a/packages/resolver/src/errors.ts b/packages/resolver/src/errors.ts index a7e30455..1e041ff8 100644 --- a/packages/resolver/src/errors.ts +++ b/packages/resolver/src/errors.ts @@ -112,7 +112,8 @@ export class ContenthashDecodeError extends ResolverError { * * Smoldot started and added the relay chain, but the peer set never produced * a finalized parachain block in time. Surfaced as a fatal so the host can - * offer the gateway escape rather than sit on the loading screen. + * show the error page with its trusted-provider retry rather than sit on the + * loading screen. */ export class NetworkSyncTimeoutError extends ResolverError { override readonly name = "NetworkSyncTimeoutError" as const; diff --git a/packages/ui/src/styles/base.css b/packages/ui/src/styles/base.css index 27a5f8df..0a238815 100644 --- a/packages/ui/src/styles/base.css +++ b/packages/ui/src/styles/base.css @@ -200,61 +200,6 @@ body { display: block; } -.loading-gateway-btn { - display: inline-flex; - align-items: center; - gap: 10px; - margin: 20px auto 0; - padding: 10px 16px 10px 14px; - border-radius: 10px; - border: 1px solid rgba(255, 255, 255, 0.16); - background: rgba(255, 255, 255, 0.03); - color: rgba(255, 255, 255, 0.92); - font-family: inherit; - cursor: pointer; - line-height: 1.2; - transition: - border-color 0.2s ease, - background 0.2s ease, - color 0.2s ease, - transform 0.15s ease; -} -.loading-gateway-btn:hover { - border-color: rgba(255, 255, 255, 0.32); - background: rgba(255, 255, 255, 0.07); - color: #fff; -} -.loading-gateway-btn:active { - transform: translateY(1px); -} -.loading-gateway-btn-icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border-radius: 7px; - background: rgba(255, 255, 255, 0.06); - color: #fafafa; - flex-shrink: 0; -} -.loading-gateway-btn-text { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 1px; -} -.loading-gateway-btn-label { - font-size: 12.5px; - font-weight: 600; - letter-spacing: -0.01em; -} -.loading-gateway-btn-sub { - font-size: 10.5px; - color: rgba(255, 255, 255, 0.5); - letter-spacing: -0.005em; -} - .accent { color: #fafafa; font-weight: 600; diff --git a/packages/ui/src/styles/themes.css b/packages/ui/src/styles/themes.css index e84cc99b..c5c29e4b 100644 --- a/packages/ui/src/styles/themes.css +++ b/packages/ui/src/styles/themes.css @@ -216,23 +216,6 @@ [data-theme="light"] .loading-hint.visible { opacity: 0.9; } -[data-theme="light"] .loading-gateway-btn { - border-color: rgba(0, 0, 0, 0.16); - background: rgba(0, 0, 0, 0.03); - color: rgba(0, 0, 0, 0.85); -} -[data-theme="light"] .loading-gateway-btn:hover { - border-color: rgba(0, 0, 0, 0.32); - background: rgba(0, 0, 0, 0.06); - color: #111; -} -[data-theme="light"] .loading-gateway-btn-icon { - background: rgba(0, 0, 0, 0.06); - color: #0a0a0a; -} -[data-theme="light"] .loading-gateway-btn-sub { - color: rgba(0, 0, 0, 0.55); -} [data-theme="light"] .loading-progress-bar { background: rgba(0, 0, 0, 0.06); } diff --git a/packages/ui/src/ui.ts b/packages/ui/src/ui.ts index f007d8d7..c1e2ad8f 100644 --- a/packages/ui/src/ui.ts +++ b/packages/ui/src/ui.ts @@ -355,56 +355,6 @@ function stopStageMessages(): void { cancelTyping(); } -export const GATEWAY_ESCAPE_DELAY_MS = 10_000; - -/** - * One-click "Use Trusted Provider" escape hatch on the loading screen. - * Renders at most once per page lifetime after `delayMs` of slow loading. - * Returns a cancel function that clears the pending timer. - */ -export function showGatewayEscape( - onClick: () => void, - delayMs: number = GATEWAY_ESCAPE_DELAY_MS, -): () => void { - const timer = setTimeout(() => { - const hint = document.getElementById("loading-hint"); - if (hint === null) { - return; - } - if (hint.querySelector(".loading-gateway-btn") !== null) { - return; - } - const btn = document.createElement("button"); - btn.className = "loading-gateway-btn"; - btn.type = "button"; - const icon = document.createElement("span"); - icon.className = "loading-gateway-btn-icon"; - icon.setAttribute("aria-hidden", "true"); - icon.innerHTML = - '' + - ''; - const text = document.createElement("span"); - text.className = "loading-gateway-btn-text"; - const label = document.createElement("span"); - label.className = "loading-gateway-btn-label"; - label.textContent = "Use Trusted Provider"; - const sub = document.createElement("span"); - sub.className = "loading-gateway-btn-sub"; - sub.textContent = "Faster but no verification"; - text.append(label, sub); - btn.append(icon, text); - btn.addEventListener("click", (ev) => { - ev.stopPropagation(); - onClick(); - }); - hint.appendChild(btn); - hint.classList.add("visible"); - }, delayMs); - return () => { - clearTimeout(timer); - }; -} - // Single-line status. Updates #status in place. Shows a slow-step // hint when a step exceeds its time threshold. @@ -480,15 +430,11 @@ function clearSlowWarning(): void { } const hint = document.getElementById("loading-hint"); if (hint !== null) { - // Remove only the text span, preserve any gateway button const textSpan = hint.querySelector(".loading-hint-text"); if (textSpan !== null) { textSpan.remove(); } - // Only hide if no gateway button is present - if (hint.querySelector(".loading-gateway-btn") === null) { - hint.classList.remove("visible"); - } + hint.classList.remove("visible"); } } @@ -508,12 +454,10 @@ export function trackStatus(message: string): void { slowTimer = setTimeout(() => { const hint = document.getElementById("loading-hint"); if (hint !== null) { - // Remove previous text span if any const existing = hint.querySelector(".loading-hint-text"); if (existing !== null) { existing.remove(); } - // Insert text as a span so it doesn't wipe the gateway button const span = document.createElement("span"); span.className = "loading-hint-text"; span.textContent = threshold.hint;