Skip to content
Open
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: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import Compare from './components/dashboard/AccountComparison'
import WalletConnect from './components/dashboard/WalletConnect'
import TransactionSigner from './components/dashboard/TransactionSigner'
import PriceTicker from './components/dashboard/PriceTicker'
import OfflineStatus from './components/layout/OfflineStatus'
import PortfolioValue from './components/dashboard/PortfolioValue'
import NetworkMetricsChart from './components/charts/NetworkMetricsChart'
import AccountActivityChart from './components/charts/AccountActivityChart'
Expand Down Expand Up @@ -349,6 +350,7 @@ function DashboardLayout() {
<div style={{ marginBottom: '16px' }}>
<PriceTicker />
</div>
<OfflineStatus />
<ErrorBoundary onRetry={handleRetry} maxRetries={2}>
{!connectedAddress ? <ConnectPanel /> : <ActiveComponent />}
</ErrorBoundary>
Expand Down
4 changes: 2 additions & 2 deletions src/components/layout/Sidebar.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from 'react'
import React, { useEffect, useState } from 'react'
import { useStore } from '../../lib/store'
import CopyableValue from '../dashboard/CopyableValue'
import { NETWORKS, updateCustomNetworkConfig, switchToCustomProfile, loadCustomNetworkProfiles } from '../../lib/stellar'
import { NETWORKS, getCustomNetworkAuthHeaders, updateCustomNetworkConfig, switchToCustomProfile, loadCustomNetworkProfiles } from '../../lib/stellar'
import { getActiveProfile } from '../../lib/userPreferences'

const SESSION_API_KEY = 'stellar_custom_api_key'
Expand Down
10 changes: 7 additions & 3 deletions src/lib/stellar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,13 @@ function saveCustomNetworkAuthHeaders(headers: Record<string, string>) {
}
}

function getNetworkHeaders(network: NetworkName): Record<string, string> {
function getNetworkHeadersForRequest(network: NetworkName): Record<string, string> {
if (network === 'custom') return getCustomNetworkAuthHeaders()
return NETWORKS[network].headers || {}
}

function withNetworkHeaders(options: RequestInit = {}, network: NetworkName): RequestInit {
const headers = getNetworkHeaders(network)
const headers = getNetworkHeadersForRequest(network)
if (!Object.keys(headers).length) return options

return {
Expand All @@ -128,7 +128,7 @@ function withNetworkHeaders(options: RequestInit = {}, network: NetworkName): Re
}

function getServerOptions(network: NetworkName) {
const headers = getNetworkHeaders(network)
const headers = getNetworkHeadersForRequest(network)
return Object.keys(headers).length ? { headers } : undefined
}

Expand Down Expand Up @@ -235,6 +235,10 @@ export function getServer(network: NetworkName = 'testnet'): StellarSdk.Horizon.
)
}

export function ee(network: NetworkName = 'testnet'): StellarSdk.Horizon.Server {
return getServer(network)
}

export function getSorobanServer(network: NetworkName = 'testnet'): StellarSdk.SorobanRpc.Server {
const config = NETWORKS[network]
if (network === 'custom' && !config.sorobanUrl) {
Expand Down
21 changes: 21 additions & 0 deletions src/main.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,24 @@ ReactDOM.createRoot(document.getElementById("root")).render(
<App />
</React.StrictMode>,
);

// Register service worker and forward messages to window
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const reg = await navigator.serviceWorker.register('/sw.js');
// forward messages from SW to window-level event
if (navigator.serviceWorker) {
navigator.serviceWorker.addEventListener('message', (e) => {
window.dispatchEvent(new CustomEvent('sw-message', { detail: e.data }));
});
}
// request initial sync registration when coming online
window.addEventListener('online', () => {
if (reg && reg.sync) reg.sync.register('sync-queue').catch(() => {});
});
} catch (err) {
// registration failed
}
});
}
121 changes: 121 additions & 0 deletions tests/e2e/offline.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { test, expect } from '@playwright/test';

test.describe('offline queue and conflict resolution', () => {
test('caches page, queues offline request, and syncs on reconnect', async ({ page }) => {
let requestCount = 0;
await page.route('**/api/test-offline', async (route) => {
requestCount += 1;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true }),
});
});

await page.goto('/');
await page.locator('button[title="User Preferences"]').waitFor({ state: 'visible', timeout: 10000 });
const swResponse = await page.request.get('/sw.js');
expect(swResponse.status()).toBe(200);

const queueLength = await page.evaluate(async () => {
const { queueRequest, getQueue } = await import('/src/lib/offlineQueue.js');
await queueRequest({
url: '/api/test-offline',
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: { value: 'local', updatedAt: Date.now() },
version: 'v1',
});
const queue = await getQueue();
return queue.length;
});
expect(queueLength).toBe(1);

await page.context().setOffline(true);
await page.waitForFunction(() => navigator.onLine === false);
await page.evaluate(() => window.dispatchEvent(new Event('offline')));
await expect(page.locator('text=You are offline')).toBeVisible();
expect(requestCount).toBe(0);

const syncRequestPromise = page.waitForRequest((request) => request.url().endsWith('/api/test-offline'), { timeout: 15000 });
await page.context().setOffline(false);
await page.waitForFunction(() => navigator.onLine === true);
await page.evaluate(() => window.dispatchEvent(new Event('online')));

await syncRequestPromise;
const remaining = await page.evaluate(async () => {
const { getQueue } = await import('/src/lib/offlineQueue.js');
const queue = await getQueue();
return queue.length;
});
expect(remaining).toBe(0);
});

test('detects conflict and allows user resolution', async ({ page }) => {
let requestCount = 0;
let resolveHeaders = {};

await page.route('**/api/conflict', async (route, request) => {
requestCount += 1;
if (requestCount === 1) {
await route.fulfill({
status: 409,
contentType: 'application/json',
body: JSON.stringify('remote'),
});
return;
}
resolveHeaders = request.headers();
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true }),
});
});

await page.goto('/');
await page.locator('button[title="User Preferences"]').waitFor({ state: 'visible', timeout: 10000 });
const swResponse = await page.request.get('/sw.js');
expect(swResponse.status()).toBe(200);

await page.evaluate(async () => {
window.__conflicts = [];
const { onQueueEvent, queueRequest } = await import('/src/lib/offlineQueue.js');
onQueueEvent((detail) => window.__conflicts.push(detail));
await queueRequest({
url: '/api/conflict',
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: 'local',
version: 'v1',
});
});

await page.context().setOffline(true);
await page.waitForFunction(() => navigator.onLine === false);
await page.evaluate(() => window.dispatchEvent(new Event('offline')));
await expect(page.locator('text=You are offline')).toBeVisible();

const conflictRequestPromise = page.waitForRequest((request) => request.url().endsWith('/api/conflict'), { timeout: 15000 });
await page.context().setOffline(false);
await page.waitForFunction(() => navigator.onLine === true);
await conflictRequestPromise;

await page.waitForFunction(
() => window.__conflicts && window.__conflicts.some((detail) => detail.type === 'conflict'),
null,
{ timeout: 15000 }
);
await expect(page.locator('text=Open Resolver')).toBeVisible();
await page.click('text=Open Resolver');
await expect(page.locator('text=Conflict Resolver')).toBeVisible();

await page.evaluate(async () => {
const conflicts = window.__conflicts;
const { resolveConflict } = await import('/src/lib/offlineQueue.js');
await resolveConflict(conflicts[0].id, { type: 'accept-local' });
});

expect(resolveHeaders['x-conflict-resolution']).toBe('accept-local');
});
});