fix: say what is happening while connecting, and stop defaulting to a light client
All checks were successful
deploy / build (push) Successful in 5m28s
deploy / deploy-web (push) Successful in 8s

The deployed page was a bare black screen for minutes with nothing but the tab
spinner to suggest it was not simply broken. Two separate faults.

**No fallback.** `main.tsx` wraps the whole app in
`<Subscribe source$={merge(dynamicBuilder$, …)}>`, and Subscribe's `fallback`
defaults to null — "it will render null until the subscription exists".
`dynamicBuilder$` does not emit until a chain has been followed and its metadata
decoded, so until then the document body is empty. On a slow connection that is
seconds of nothing; on a chain the console cannot read it is forever, and a
visitor cannot tell the two apart. There is now a splash naming the chain and
the endpoint, which escalates at 8s and again at 25s and tells the reader how to
force a different network.

**The default endpoint was the light client.** `defaultSelectedChain` used
LIGHT_CLIENT_ENDPOINT unconditionally, which routes through smoldot — and
smoldot needs a chain spec. The Quantus networks have none
(`hasChainSpecs: false`), so the default asked it to follow a chain it had never
heard of. It now picks AUTO_RPC for any network without chain specs, which is
what the endpoints in quantus.json are for.

This does not make the console work against Quantus. It makes it explain itself
while failing, which it could not do before — the remaining fault is much
deeper and is written up on #1.

Refs #1, #3

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
This commit is contained in:
2026-09-16 09:14:22 +03:00
parent 40927fa910
commit 0448d9df7a
3 changed files with 107 additions and 4 deletions

View File

@@ -0,0 +1,81 @@
import { FC, useEffect, useState } from "react"
/**
* What the page shows while the chain is being reached.
*
* `main.tsx` wraps the entire app in `<Subscribe source$={dynamicBuilder$ …}>`,
* and `Subscribe`'s fallback defaults to `null` — "it will render null until the
* subscription exists". `dynamicBuilder$` does not emit until a chain has been
* followed and its metadata decoded, so until then the document body is empty
* and a visitor sees a bare black page with nothing but the tab spinner to
* suggest it is not simply broken.
*
* That is indistinguishable from a crash, and on a chain the console cannot read
* it never resolves at all. So: say what is happening, and say it louder the
* longer it takes.
*/
const SLOW_MS = 8_000
const STUCK_MS = 25_000
export const BootSplash: FC<{ endpoint?: string; network?: string }> = ({
endpoint,
network,
}) => {
const [elapsed, setElapsed] = useState(0)
useEffect(() => {
const started = Date.now()
const id = setInterval(() => setElapsed(Date.now() - started), 500)
return () => clearInterval(id)
}, [])
return (
<div className="flex h-screen w-full flex-col items-center justify-center gap-4 bg-background px-6 text-center">
<div className="text-base">
<span className="poppins-regular">qapi</span>{" "}
<span className="poppins-extralight">console</span>
</div>
<div
className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent"
role="status"
aria-label="connecting"
/>
<div className="text-sm text-muted-foreground">
Connecting to {network ?? "the chain"}
{endpoint ? (
<>
{" "}
at <code className="break-all">{endpoint}</code>
</>
) : null}
</div>
{elapsed > SLOW_MS && elapsed <= STUCK_MS ? (
<div className="max-w-md text-sm text-muted-foreground">
Still going. The console downloads the chain's metadata and decodes it
before it can render anything, which takes a moment on a first visit.
</div>
) : null}
{elapsed > STUCK_MS ? (
<div className="max-w-md space-y-2 text-sm">
<p className="text-destructive">
This is taking much longer than it should.
</p>
<p className="text-muted-foreground">
Either the node is unreachable, or the console cannot read this
chain. Check the browser console for the reason, and try another
network the switcher is in the header once any chain loads, and{" "}
<code>#networkId=polkadot&amp;endpoint=wss://rpc.polkadot.io</code>{" "}
in the URL will force one.
</p>
</div>
) : null}
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { dynamicBuilder$ } from "@/state/chains/chain.state"
import { dynamicBuilder$, getDefaultChain } from "@/state/chains/chain.state"
import { RemoveSubscribe, Subscribe } from "@react-rxjs/core"
import { MultisigExternalSignerModal, PolkaHubProvider } from "polkahub"
import { StrictMode } from "react"
@@ -8,6 +8,7 @@ import { merge } from "rxjs"
import App from "./App.tsx"
import { TooltipProvider } from "./components/ui/tooltip.tsx"
import "./index.css"
import { BootSplash } from "./components/BootSplash.tsx"
import { codeSplit$ } from "./lib/externalState.ts"
import { explorer$ } from "./pages/Explorer"
import { polkaHub } from "./state/polkahub.ts"
@@ -17,8 +18,22 @@ const metrics$ = codeSplit$(() =>
import("./pages/Metrics/metrics.state.ts").then((r) => r.blockStats$),
)
// Without a fallback, `Subscribe` renders null until `dynamicBuilder$` emits —
// which is after a chain has been followed and its metadata decoded. On a slow
// connection that is seconds of empty black page; on a chain the console cannot
// read, it is forever, and indistinguishable from a crash.
const initial = getDefaultChain()
createRoot(document.getElementById("root")!).render(
<Subscribe source$={merge(dynamicBuilder$, explorer$, metrics$)}>
<Subscribe
fallback={
<BootSplash
endpoint={initial.endpoint}
network={initial.network.display}
/>
}
source$={merge(dynamicBuilder$, explorer$, metrics$)}
>
<RemoveSubscribe>
<StrictMode>
<ThemeProvider>

View File

@@ -145,10 +145,17 @@ export const isValidUri = (input: string): boolean => {
const defaultSelectedChain: SelectedChain = {
network: defaultNetwork,
endpoint: LIGHT_CLIENT_ENDPOINT,
// AUTO_RPC, not LIGHT_CLIENT. A light client needs a chain spec, and the
// Quantus networks have none (`hasChainSpecs: false`), so defaulting to
// smoldot asks it to follow a chain it has never heard of — which fails
// before a single byte is fetched, and with `Subscribe`'s null fallback it
// failed as a bare black page.
endpoint: defaultNetwork.lightclient
? LIGHT_CLIENT_ENDPOINT
: AUTO_RPC_ENDPOINT,
forkMethod: "none",
}
const getDefaultChain = (): SelectedChain => {
export const getDefaultChain = (): SelectedChain => {
const hashParams = getHashParams()
if (hashParams.has("networkId") && hashParams.has("endpoint")) {
const networkId = hashParams.get("networkId")!