Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"message": "Hard-coded rgb()/rgba() colours are not allowed in JSX. Use design tokens from src/lib/coverage/metadata.ts or CSS custom properties (see CONTRIBUTING.md)."
},
{
"selector": "Literal[value=1e7]",
"selector": "Literal[value=10000000]",
"message": "Use USDC_BASE_UNIT / fromStroops from src/lib/format.ts instead of the literal 1e7."
},
{
Expand Down
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,32 @@ jobs:
- name: Run ESLint
run: npm run lint

realtime-benchmark:
name: Realtime render benchmark
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 20
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run dev &
env:
NEXT_PUBLIC_REALTIME_PROFILER: "1"
- run: |
for attempt in $(seq 1 60); do
if curl --fail --silent http://localhost:3000 >/dev/null; then exit 0; fi
sleep 1
done
echo "Next.js dev server did not become ready" >&2
exit 1
- run: npm run realtime:benchmark
env:
REALTIME_BENCHMARK_URL: http://127.0.0.1:3000

typecheck:
name: Typecheck
runs-on: ubuntu-latest
Expand Down
9 changes: 9 additions & 0 deletions docs/realtime-performance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Realtime resilience and performance

Realtime data uses the existing REST endpoints during cold start and after subscription failure. The client caches topic snapshots by network, wallet and topic; entries carry a version and capture time and render as stale until a REST response or realtime push supersedes them. IndexedDB is optional, and storage failures never block the page.

`RealtimeBoundary` isolates a route's live region. A render or subscription handler failure marks the topic degraded and renders its fallback. Retry clears that topic's degraded state. Diagnostics include topic, route, transport and error type only; payloads and wallet addresses are omitted.

The development-only React Profiler records commits and actual durations by topic. Run `NEXT_PUBLIC_REALTIME_PROFILER=1 npm run dev`, then `npm run realtime:benchmark`. The scripted benchmark measures five runs with the socket disconnected and five runs with 100 oracle pushes at 25 Hz on landing, `/cover` and `/provide`; it reports both scenarios' median commit time, p95 commit time, long tasks, and p98 event duration to `reports/realtime-benchmark.json`.

Budgets are checked in at `reports/realtime-benchmark-baseline.json`: median commit duration below 16 ms, p95 commit duration below 50 ms, no long tasks during the scripted stream, and p98 interaction duration below 200 ms. These thresholds allow CI variance while flagging work that exceeds a frame or the Core Web Vitals INP “good” threshold. Lighthouse CI separately warns on INP above 200 ms and total blocking time above 200 ms. Absolute measurements vary by runner; compare like-for-like CI runs and use the median of five benchmark runs.
2 changes: 2 additions & 0 deletions lighthouserc.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ module.exports = {
'categories:accessibility': ['error', { minScore: 0.8 }],
'categories:best-practices': ['error', { minScore: 0.8 }],
'categories:seo': ['error', { minScore: 0.8 }],
'interaction-to-next-paint': ['warn', { maxNumericValue: 200 }],
'total-blocking-time': ['warn', { maxNumericValue: 200 }],
},
},
upload: {
Expand Down
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test": "vitest run --config vitest.config.mts",
"test:watch": "vitest --config vitest.config.mts",
"test:coverage": "vitest run --config vitest.config.mts --coverage",
"test:visual": "playwright test --project=visual",
"test:visual:update": "playwright test --project=visual --update-snapshots",
"audit": "audit-ci --config audit-ci.json",
Expand All @@ -20,6 +20,7 @@
"size": "size-limit",
"size:why": "size-limit --why",
"tokens:check": "node scripts/check-tokens.mjs",
"realtime:benchmark": "node scripts/realtime-benchmark.mjs",
"mock:realtime": "node scripts/mock-realtime-server.mjs"
},
"lint-staged": {
Expand Down
10 changes: 10 additions & 0 deletions reports/realtime-benchmark-baseline.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"schemaVersion": 1,
"scenario": "100 oracle pushes at 25Hz across landing, /cover, and /provide; median of five browser runs",
"budgets": {
"medianCommitDurationMs": 16,
"p95CommitDurationMs": 50,
"longTaskCount": 0,
"p98InteractionDurationMs": 200
}
}
73 changes: 73 additions & 0 deletions scripts/realtime-benchmark.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { chromium } from "@playwright/test";
import { readFile, writeFile } from "node:fs/promises";

const origin = process.env.REALTIME_BENCHMARK_URL ?? "http://127.0.0.1:3000";
const baseline = JSON.parse(await readFile(new URL("../reports/realtime-benchmark-baseline.json", import.meta.url), "utf8"));
const routes = ["/", "/cover", "/provide"];
const runs = Number(process.env.REALTIME_BENCHMARK_RUNS ?? 5);
const browser = await chromium.launch({ headless: true });
const samples = { disconnected: [], streaming: [] };
for (let index = 0; index < runs; index += 1) {
for (const scenario of ["disconnected", "streaming"]) {
const context = await browser.newContext();
const page = await context.newPage();
await page.addInitScript(() => {
window.__realtimeVitals = { longTasks: [], interactions: [] };
try {
new PerformanceObserver((list) => window.__realtimeVitals.longTasks.push(...list.getEntries().map((entry) => entry.duration)))
.observe({ type: "longtask", buffered: true });
new PerformanceObserver((list) => window.__realtimeVitals.interactions.push(...list.getEntries().map((entry) => entry.duration)))
.observe({ type: "event", buffered: true, durationThreshold: 16 });
} catch { /* event timing is not supported by every browser build */ }
});
await page.routeWebSocket("**/ws", (socket) => {
if (scenario === "disconnected") { socket.close(); return; }
let push = 0;
const timer = setInterval(() => {
push += 1;
socket.send(JSON.stringify({ topic: "oracle_update", payload: [
{ coverageType: "StablecoinDepeg", type: "oracle_update", value: 1 + push / 100000, threshold: 0.95, severity: "low", message: "benchmark" },
] }));
if (push >= 100) clearInterval(timer);
}, 40);
});
for (const route of routes) {
await page.goto(`${origin}${route}`, { waitUntil: "domcontentloaded" });
await page.waitForTimeout(250);
await page.locator("body").click({ position: { x: 10, y: 10 } }).catch(() => undefined);
}
await page.waitForTimeout(4200);
const result = await page.evaluate(() => ({
profile: window.__REFRACT_REALTIME_PROFILE__ ?? {},
longTasks: window.__realtimeVitals.longTasks,
interactions: window.__realtimeVitals.interactions,
}));
samples[scenario].push(result);
await context.close();
}
}
await browser.close();
const median = (values) => values.sort((a, b) => a - b)[Math.floor(values.length / 2)] ?? 0;
const measure = (scenarioSamples) => {
const commitDurations = scenarioSamples.flatMap((sample) => Object.values(sample.profile).flatMap((item) => item.durations ?? []));
const longTasks = scenarioSamples.flatMap((sample) => sample.longTasks);
const interactions = scenarioSamples.flatMap((sample) => sample.interactions);
return {
medianCommitDurationMs: median(commitDurations),
p95CommitDurationMs: commitDurations.sort((a, b) => a - b)[Math.floor(commitDurations.length * 0.95)] ?? 0,
medianCommitsPerTopic: Object.fromEntries(["oracle_update", "claim_settlement", "pool_stats"].map((topic) => [topic, median(scenarioSamples.map((sample) => sample.profile[topic]?.commits ?? 0))])),
longTaskCount: longTasks.length,
medianLongTaskDurationMs: median(longTasks),
p98InteractionDurationMs: interactions.sort((a, b) => a - b)[Math.floor(interactions.length * 0.98)] ?? 0,
};
};
const disconnected = measure(samples.disconnected);
const streaming = measure(samples.streaming);
const report = {
generatedAt: new Date().toISOString(), runs, routes,
scenarios: { disconnected, streaming },
budget: baseline.budgets,
};
await writeFile("reports/realtime-benchmark.json", `${JSON.stringify(report, null, 2)}\n`);
console.log(JSON.stringify(report, null, 2));
if (streaming.medianCommitDurationMs > report.budget.medianCommitDurationMs || streaming.p95CommitDurationMs > report.budget.p95CommitDurationMs || streaming.longTaskCount > report.budget.longTaskCount || streaming.p98InteractionDurationMs > report.budget.p98InteractionDurationMs) process.exitCode = 1;
48 changes: 25 additions & 23 deletions src/app/cover/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ import { parseCoverQueryParams } from "@/lib/coverQueryParams";
import { PolicyCertificateModal } from "@/components/PolicyCertificate";
import { validateBuyCoverage } from "@/lib/validation/coverage";
import { searchFlights, POPULAR_FLIGHTS, type FlightInfo } from "@/lib/flights";
import { RealtimeBoundary } from "@/components/realtime/RealtimeBoundary";
import { RealtimeProfiler } from "@/lib/realtime/devProfiler";
import { publishAccountBalanceDelta } from "@/hooks/useAccountBalance";

const RISK_TAG_COLORS: Record<string, string> = {
low: "#10b981",
Expand Down Expand Up @@ -306,6 +309,7 @@ export default function CoverPage() {
return;
}
const txHash = await signAndSubmit(result.txXdr, wallet.address, wallet.networkPassphrase, { hardware: wallet.hardwareWallet });
publishAccountBalanceDelta(wallet.address, "USDC", -fromStroops(result.policy.premium));
setSubmission({ status: "success", result, demo: false, txHash });
updateTransaction(txId, { status: "confirmed", txHash });
} catch (err) {
Expand Down Expand Up @@ -419,7 +423,8 @@ export default function CoverPage() {
<div className="flex flex-col gap-5">
{/* STEP 1: Select Type */}
{currentStep === 1 && (
<div data-tour-id="cover-type">
<RealtimeBoundary topic="coverage_types" route="/cover" fallback={() => <div role="status" className="rounded-lg border border-pm-border p-5 text-sm text-pm-text/60">Coverage types are temporarily unavailable. Try again later or open the Dashboard.</div>}>
<RealtimeProfiler topic="coverage_types"><div data-tour-id="cover-type">
<div className="mb-3 text-[11px] uppercase tracking-wide text-pm-text/40">
Step 1 of 4: Select Coverage Type
</div>
Expand All @@ -430,30 +435,13 @@ export default function CoverPage() {
</Card>
)}

{typesLoading && (
<div className="flex flex-col gap-2.5" role="status" aria-label="Loading coverage types">
{Array.from({ length: 5 }).map((_, i
</div>

{typesError && (
<Card className="border-pm-red/30 !bg-pm-red/[0.04]">
<p className="text-sm text-pm-red">Couldn&apos;t load coverage types: {typesError}</p>
</Card>
)}

{typesLoading && (
{typesLoading && !coverageTypes && (
<div className="flex flex-col gap-2.5" role="status" aria-label="Loading coverage types">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} height={72} rounded="md" />
))}
</div>
}
errorRender={(err) => (
<Card className="border-pm-red/30 !bg-pm-red/[0.04]">
<p className="text-sm text-pm-red">Couldn&apos;t load coverage types: {err}</p>
</Card>
)}
>
{coverageTypes && (
<div
className="flex flex-col gap-2.5"
Expand Down Expand Up @@ -532,9 +520,6 @@ export default function CoverPage() {
})}
</div>
)}
</AsyncState>
</div>

<div className="mt-6 flex justify-end">
<Button
type="button"
Expand All @@ -545,7 +530,8 @@ export default function CoverPage() {
Next: Trigger Parameters →
</Button>
</div>
</div>
</div></RealtimeProfiler>
</RealtimeBoundary>
)}

{/* STEP 2: Trigger Parameters */}
Expand Down Expand Up @@ -715,6 +701,22 @@ export default function CoverPage() {
</span>
</div>
<input
id="duration"
type="range"
min={1}
max={365}
value={durationDays}
onChange={(event) => setDurationDays(Number(event.target.value))}
className="w-full accent-violet-500"
/>
</div>
<div className="mt-6 flex items-center justify-between border-t border-pm-border pt-4">
<Button type="button" variant="outline" onClick={() => setCurrentStep(2)}>← Back</Button>
<Button type="button" variant="primary" onClick={() => setCurrentStep(4)} disabled={amountInvalid}>Review Purchase →</Button>
</div>
</Card>
)}
</div>

{/* Right: Quote panel */}
<div className="flex flex-col gap-5 lg:sticky lg:top-20">
Expand Down
8 changes: 6 additions & 2 deletions src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
import type { Policy } from "@/lib/api/policies";
import type { ClaimRecord } from "@/lib/api/claims";
import { normalizePolicies, normalizeClaims, selectPolicyStatus, type PolicyStatus } from "@/lib/entities/policies";
import { RealtimeBoundary } from "@/components/realtime/RealtimeBoundary";
import { RealtimeProfiler } from "@/lib/realtime/devProfiler";

const COVERAGE_ICONS = ["🪙", "📉", "🛡️", "🔐", "✈️"];
const COVERAGE_COLORS = ["#8b5cf6", "#f59e0b", "#10b981", "#ef4444", "#06b6d4"];
Expand Down Expand Up @@ -74,7 +76,8 @@ export default function DashboardPage() {
<div className="min-h-screen bg-pm-bg">
<Navbar right={<WalletButton />} />

<main id="main-content">
<RealtimeBoundary topic="claim_settlement" route="/dashboard" fallback={() => <main id="main-content"><Container className="py-16"><p role="status">Live claim updates are temporarily unavailable. Reconnect to retry; cached policy data remains available.</p></Container></main>}>
<RealtimeProfiler topic="claim_settlement"><main id="main-content">
<Container className="py-9 sm:py-10">
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
Expand Down Expand Up @@ -230,7 +233,8 @@ export default function DashboardPage() {
</>
)}
</Container>
</main>
</main></RealtimeProfiler>
</RealtimeBoundary>

<Footer />
</div>
Expand Down
15 changes: 11 additions & 4 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { formatUsd, formatRelativeTime, fromStroops } from "@/lib/format";
import { truncateAddress } from "@/lib/wallet/WalletProvider";
import { ACTIVE_NETWORK } from "@/lib/network";
import { HowItWorks } from "@/components/HowItWorks";
import { RealtimeBoundary } from "@/components/realtime/RealtimeBoundary";
import { RealtimeProfiler } from "@/lib/realtime/devProfiler";

function Counter({ to, prefix = "", suffix = "", decimals = 0 }: { to: number; prefix?: string; suffix?: string; decimals?: number }) {
const [val, setVal] = useState(0);
Expand Down Expand Up @@ -176,7 +178,8 @@ export default function Home() {

{/* Hero right: Live coverage card */}
<div className="relative">
<Card className="relative z-10" aria-label="Live oracle status">
<RealtimeBoundary topic="oracle_update" route="/" fallback={() => <Card className="relative z-10" aria-label="Live oracle status"><p role="status">Live oracle readings are unavailable. Other Refract sections remain available.</p></Card>}>
<RealtimeProfiler topic="oracle_update"><Card className="relative z-10" aria-label="Live oracle status">
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs uppercase tracking-wide text-pm-text/60">Live Oracle Status</span>
Expand Down Expand Up @@ -253,7 +256,8 @@ export default function Home() {
: "Oracles auto-refreshed every 60 seconds"}
</div>
</div>
</Card>
</Card></RealtimeProfiler>
</RealtimeBoundary>
<div
className="pointer-events-none absolute left-1/2 top-1/2 -z-0 h-[300px] w-[300px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-pm-violet/[0.12] blur-[60px]"
aria-hidden="true"
Expand Down Expand Up @@ -349,7 +353,8 @@ export default function Home() {
<HowItWorks />

{/* Recent payouts */}
<section aria-labelledby="payouts-heading" className="py-16">
<RealtimeBoundary topic="claim_settlement" route="/" fallback={() => <section aria-labelledby="payouts-heading" className="py-16"><Container><h2 id="payouts-heading" className="font-display text-2xl font-extrabold text-pm-text">Recent payouts</h2><p role="status" className="mt-3 text-sm text-pm-text/50">Payout updates are temporarily unavailable.</p></Container></section>}>
<RealtimeProfiler topic="claim_settlement"><section aria-labelledby="payouts-heading" className="py-16">
<Container>
<h2 id="payouts-heading" className="mb-7 font-display text-2xl font-extrabold tracking-tight text-pm-text sm:text-[26px]">
Recent payouts
Expand All @@ -359,6 +364,7 @@ export default function Home() {
⚠ Showing example data — the Refract API isn&apos;t reachable from this environment.
</p>
)}
{recentClaims.isStale && <p className="mb-4 text-[11px] text-pm-text/40">Saved payouts — refreshing live data…</p>}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{(recentClaims.data ?? []).map((claim) => (
<Card key={claim.policyId} padding="sm" className="!p-5">
Expand All @@ -376,7 +382,8 @@ export default function Home() {
))}
</div>
</Container>
</section>
</section></RealtimeProfiler>
</RealtimeBoundary>
</main>

<Footer />
Expand Down
Loading