diff --git a/README.md b/README.md index a9e6b6cf..e2d1c139 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,35 @@ The server will be available at To display debugging information for the Rx streams in the web developer console, set `localStorage.debug = '*'` and refresh. +### Client polling + +Browser polling uses three shared cadence tiers defined in `client/src/const.js`: + +- **Fast (30 seconds):** chain tip, pending block templates, and recent mempool + transactions shown on the dashboard and recent-transactions page. +- **Standard (60 seconds):** mempool summary, fee estimates, and pending peg + transactions. +- **Slow (10 minutes):** the Bitcoin market chart, the Liquid high-value-assets + panel, and a block-list safety poll used to detect same-height reorganizations. + +View-scoped polls run only while their view is active and the browser window is +focused. Their first tick occurs after a full interval because route entry +already requests the initial data. This avoids sending duplicate requests when +opening a page. The chain tip is global and also refreshes immediately when the +application starts or a block, transaction, or address page is opened. + +The block list refreshes when the chain tip height changes, with the slow safety +poll covering same-height reorganizations. Confirmed peg state and peg chain +transactions also refresh on new blocks rather than on a timer. A newly observed +tip resets pending block template polling and delays the next request by 15 +seconds so the electrs cache can refresh. + +The dashboard requests both `/mempool/recent` and `/mempool` because they serve +different UI contracts. `/mempool/recent` supplies the recent transaction list; +`/mempool` supplies aggregate backlog fields such as transaction count, virtual +size, total fees, and the fee histogram used by congestion and transaction fee +analysis. + ## Building To build the static assets directory for production deployment, set config options (see below) diff --git a/client/src/app.js b/client/src/app.js index e8249f81..eced4bbc 100644 --- a/client/src/app.js +++ b/client/src/app.js @@ -13,6 +13,7 @@ import { blockTxsPerPage, blocksPerPage, difficultyPeriod, + pollIntervalsMs, showHighValueAssets, showPegData, blockGridTransactionSelectEvent @@ -30,7 +31,8 @@ import { isHash256, makeAddressQR, tickWhileFocused, - tickWhileViewing, + pollWhileActive, + pollWhileViewing, updateBlocks, calculateFeerates, calculateOverpayment, @@ -42,7 +44,8 @@ const highValueAssetCategory = assetId => `dashboard-high-value-asset-${assetId} , highValueAssetPriceCategory = assetId => `dashboard-high-value-asset-price-${assetId}` , apiBase = (process.env.API_URL || '/api').replace(/\/+$/, '') , bitcoinMarketChartUrl = process.env.BITCOIN_MARKET_CHART_URL || 'https://api.coingecko.com/api/v3/coins/bitcoin/market_chart?vs_currency=usd&days=1&interval=hourly' - , blockTemplatePollIntervalMs = 30000 + , blockTemplatePollIntervalMs = pollIntervalsMs.fast + , tipRequestThrottleMs = 5000 // Wait one electrs cache window after a new tip before requesting the next // template. If a refresh is still in progress, electrs holds the request // until fresh data is ready, so no additional client-side jitter is needed. @@ -137,6 +140,30 @@ export const scheduleDashboardBlockTemplatePolls = ( .filter(({ view }) => view == 'dashBoard') .map(({ pollIndex }) => pollIndex) +export const dashboardNewBlocks = (page$, latestBlock$) => + page$ + .switchMap(page => page && page.pathname == '/' + ? latestBlock$.skip(1) + : O.empty()) + +export const parseTipHeight = text => { + const normalized = typeof text == 'string' ? text.trim() : text + , height = typeof normalized == 'string' && /^\d+$/.test(normalized) + ? Number(normalized) + : normalized + + return Number.isSafeInteger(height) && height >= 0 ? height : null +} + +export const subsequentTipHeights = tipHeight$ => + tipHeight$.distinctUntilChanged().skip(1) + +export const tipDrivenBlockListRefreshes = (tipHeight$, view$) => + tipHeight$ + .withLatestFrom(view$, (height, view) => ({ height, view })) + .filter(({ view }) => view == 'recentBlocks' || view == 'dashBoard') + .map(({ height }) => height) + const trackPendingBlockTemplateUpdate = (previous, template) => { if (!template || !Array.isArray(template.transactions)) { return { template: null, key: null, transactionCount: null, delta: null } @@ -181,10 +208,38 @@ export const trackPendingBlockTemplateEvent = (previous, event) => { } } -export default function main({ DOM, HTTP, route, storage, scanner: scan$, search: searchResult$, blinding: unblinded$ }) { +export default function main( + { DOM, HTTP, route, storage, scanner: scan$, search: searchResult$, blinding: unblinded$ }, + { + pollingEnabled=process.browser, + pollingScheduler, + hasFocus=() => document.hasFocus() + }={} +) { const reply = (cat, raw) => dropErrors(HTTP.select(cat)).map(r => raw ? r : (r.body || r.text)) + , focusedTicks = ms => tickWhileFocused( + ms, + pollingScheduler, + hasFocus, + pollingEnabled + ) + , pollActive = (ms, activeKey$) => pollWhileActive( + ms, + activeKey$, + pollingScheduler, + hasFocus, + pollingEnabled + ) + , pollViewing = (ms, views, view$) => pollWhileViewing( + ms, + views, + view$, + pollingScheduler, + hasFocus, + pollingEnabled + ) , recoverableReply = cat => O.merge( reply(cat).map(value => ({ value, succeeded: true })) , extractErrors(HTTP.select(cat)).mapTo({ succeeded: false })) @@ -272,7 +327,10 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search , error$ = extractErrors(HTTP.select().filter(r$ => !r$.request.bg)) .merge(searchResult$.filter(found => !found).mapTo('No results found')) - , tipHeight$ = reply('tip-height', true).map(res => +res.text) + , tipHeight$ = reply('tip-height', true) + .map(res => parseTipHeight(res.text)) + .filter(notNully) + , subsequentTipHeight$ = subsequentTipHeights(tipHeight$) // the translation function for the currently selected language , t$ = lang$.map(lang => l10n[lang] || l10n[defaultLang]) @@ -523,17 +581,29 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search .distinctUntilChanged((a, b) => a.height == b.height) : O.empty() - , dashboardNewBlock$ = latestBlock$.skip(1) - .withLatestFrom(view$) - .filter(([ _, view ]) => view == 'dashBoard') + // Route entry already requests all dashboard data. Treat its first block-list + // response as the baseline, then refresh block-dependent data on later tips. + , dashboardNewBlock$ = dashboardNewBlocks(page$, latestBlock$) + + , mempoolPollKey$ = O.combineLatest(view$, tx$, (view, tx) => + view == 'dashBoard' || view == 'mempool' + ? view + : view == 'tx' && tx && !tx.status.confirmed + ? tx.txid + : null) - // In the browser, wait for the ready dashboard view. Liquid remains in the - // loading view until its asset map arrives, so starting from goHome$ would - // discard the initial template request. - , blockTemplatePoll$ = !process.browser + , feeEstimatePollKey$ = O.combineLatest( + mempoolPollKey$, + view$, + (mempoolPollKey, view) => view == 'recentTxs' ? view : mempoolPollKey) + + , blockTemplatePoll$ = !pollingEnabled ? goHome$ - : scheduleDashboardBlockTemplatePolls(view$, dashboardNewBlock$) - .filter(pollIndex => pollIndex == 0 || document.hasFocus()) + : scheduleDashboardBlockTemplatePolls( + view$, + dashboardNewBlock$, + pollingScheduler + ).filter(pollIndex => pollIndex == 0 || hasFocus()) , dashboardEpochStartHeight$ = dashboardLatestBlock$ .map(block => block.height - (block.height % difficultyPeriod)) @@ -607,11 +677,20 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search ? { category: 'addr-txs', method: 'GET', path: `/address/${d.addr}/txs/chain/${last(d.last_txids)}` } : { category: 'addr-txs', method: 'GET', path: `/address/${d.addr}/txs` }]) - // fetch list of blocks for homepage - , O.merge(goBlocks$, moreBlocks$) - .merge(process.browser ? O.timer(60000, 60000).withLatestFrom(view$) - .filter(([ _, view ]) => view == 'recentBlocks' || view == 'dashBoard') - .mapTo({ start_height: null }) : O.empty()) + // Fetch the block list on route entry, when the tip height changes, and as + // a slow safety refresh. The timer exists specifically to catch same-height + // reorgs, which cannot be detected through /blocks/tip/height. + , O.merge( + goBlocks$, + moreBlocks$, + tipDrivenBlockListRefreshes(subsequentTipHeight$, view$) + .mapTo({ start_height: null }), + pollViewing( + pollIntervalsMs.slow, + [ 'recentBlocks', 'dashBoard' ], + view$ + ).mapTo({ start_height: null }) + ) .map(d => ({ category: 'blocks', method: 'GET', path: `/blocks/${d.start_height == null ? '' : d.start_height}` })) // fetch more txs for block page @@ -645,7 +724,7 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search // in browser env, get the tip every 30s (but only when the page is active) or when we render a block/tx/addr, but not more than once every 5s // in server env, just get it once - , (process.browser ? O.merge(tickWhileFocused(30000), goBlock$, goTx$, goAddr$).throttleTime(5000) + , (pollingEnabled ? O.merge(focusedTicks(pollIntervalsMs.fast), goBlock$, goTx$, goAddr$).throttleTime(tipRequestThrottleMs, pollingScheduler) : O.of(1) ).mapTo( { category: 'tip-height', method: 'GET', path: '/blocks/tip/height', bg: !!process.browser } ) @@ -653,33 +732,42 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search , goMempool$.flatMap(_ => [{ category: 'mempool', method: 'GET', path: '/mempool' } , { category: 'fee-est', method: 'GET', path: '/fee-estimates' }]) - // fetch backlog stats and fee estimates in the background when opening a tx, or every 30 seconds while the mempool or unconfirmed tx page remains open - , goTx$.merge(process.browser ? tickWhileFocused(30000).withLatestFrom(view$, tx$) - .filter(([ _, view, tx ]) => view == 'mempool' - || (view == 'tx' && tx && !tx.status.confirmed)) - : O.empty()) - .flatMap(_ => [{ category: 'mempool', method: 'GET', path: '/mempool', bg: !!process.browser } + // fetch backlog stats and fee estimates in the background when opening a tx + , goTx$.flatMap(_ => [{ category: 'mempool', method: 'GET', path: '/mempool', bg: !!process.browser } , { category: 'fee-est', method: 'GET', path: '/fee-estimates', bg: !!process.browser }]) + // poll each dynamic endpoint only on views that consume it + , pollActive(pollIntervalsMs.standard, mempoolPollKey$) + .mapTo( { category: 'mempool', method: 'GET', path: '/mempool', bg: true }) + + , pollActive(pollIntervalsMs.standard, feeEstimatePollKey$) + .mapTo( { category: 'fee-est', method: 'GET', path: '/fee-estimates', bg: true }) + // fetch recent mempool txs and fee estimates when opening the recent txs page , goRecent$.flatMap(_ => [{ category: 'recent', method: 'GET', path: '/mempool/recent' } , { category: 'fee-est', method: 'GET', path: '/fee-estimates' }]) - // ... and every 5 seconds while it remains open - , tickWhileViewing(5000, 'recentTxs', view$) - .mapTo( { category: 'recent', method: 'GET', path: '/mempool/recent', bg: true }) - // ... and every 5 seconds while dashBoard remains open - , tickWhileViewing(5000, 'dashBoard', view$) + // ... and on the fast cadence while either transaction list remains open + , pollViewing( + pollIntervalsMs.fast, + [ 'recentTxs', 'dashBoard' ], + view$ + ) .mapTo( { category: 'recent', method: 'GET', path: '/mempool/recent', bg: true }) - // refresh overview panels while dashboard remains open - , tickWhileViewing(60000, 'dashBoard', view$) - .flatMap(_ => [{ category: 'fee-est', method: 'GET', path: '/fee-estimates', bg: true } - , { category: 'mempool', method: 'GET', path: '/mempool', bg: true } - , { category: 'bitcoin-market-chart', method: 'GET', path: bitcoinMarketChartUrl, bg: true }] - .concat(!showPegData ? [] : + // refresh pending peg transactions on the standard cadence + , !showPegData ? O.empty() : + pollViewing(pollIntervalsMs.standard, 'dashBoard', view$) + .mapTo({ category: 'dashboard-peg-mempool-txs', method: 'GET', path: `/asset/${nativeAssetId}/txs/mempool`, bg: true }) + + // the market chart contains hourly samples and uses the slow cadence + , pollViewing(pollIntervalsMs.slow, 'dashBoard', view$) + .mapTo({ category: 'bitcoin-market-chart', method: 'GET', path: bitcoinMarketChartUrl, bg: true }) + + // confirmed peg state changes only when a new block arrives + , !showPegData ? O.empty() : + dashboardNewBlock$.flatMap(_ => [{ category: 'dashboard-peg-asset', method: 'GET', path: `/asset/${nativeAssetId}`, bg: true } - , { category: 'dashboard-peg-chain-txs', method: 'GET', path: `/asset/${nativeAssetId}/txs/chain`, bg: true } - , { category: 'dashboard-peg-mempool-txs', method: 'GET', path: `/asset/${nativeAssetId}/txs/mempool`, bg: true }])) + , { category: 'dashboard-peg-chain-txs', method: 'GET', path: `/asset/${nativeAssetId}/txs/chain`, bg: true }]) // Refresh the pending block template while the dashboard remains open. A new // tip resets the cadence and waits for electrs' block cache to refresh. @@ -700,8 +788,7 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search // fetch asset stats and USD prices only while viewing the Liquid dashboard , !showHighValueAssets ? O.empty() : - O.merge(goHome$, tickWhileViewing(60000, 'dashBoard', view$)) - .throttleTime(1000) + O.merge(goHome$, pollViewing(pollIntervalsMs.slow, 'dashBoard', view$)) .flatMap(_ => highValueAssetRequests) // // elements/liquid only diff --git a/client/src/const.js b/client/src/const.js index 6600650d..bab1ef87 100644 --- a/client/src/const.js +++ b/client/src/const.js @@ -13,6 +13,11 @@ export const typicalDiscountedConfidentialTransactionVsize = 258 export const maxBlockWeight = 4000000 export const blockGridLoadingDelayMs = 100 export const blockGridTransactionSelectEvent = 'block-grid-transaction-select' +export const pollIntervalsMs = { + fast: 30000, + standard: 60000, + slow: 10 * 60 * 1000, +} export const feeEstimateTargets = { low: 12, average: 3, diff --git a/client/src/util.js b/client/src/util.js index 2efe85ac..951a5a0c 100644 --- a/client/src/util.js +++ b/client/src/util.js @@ -131,15 +131,55 @@ export const extractErrors = r$$ => // Create a stream that ticks every `ms`, but only when the window is focused. // Returns an empty stream in the server-side pre-renderer environment. -export const tickWhileFocused = ms => - process.browser - ? O.timer(0, ms).filter(() => document.hasFocus()) +export const tickWhileFocused = ( + ms, + scheduler, + hasFocus=() => document.hasFocus(), + enabled=process.browser +) => + enabled + ? O.timer(0, ms, scheduler).filter(hasFocus) : O.empty() -// Create a stream that ticks every `ms` w, but only when the window is focused -// *and* `view` is the active view -export const tickWhileViewing = (ms, view, view$) => - tickWhileFocused(ms).withLatestFrom(view$).filter(([ _, shown_view ]) => shown_view == view) +export const schedulePollsWhileActive = (ms, activeKey$, scheduler) => + activeKey$ + .distinctUntilChanged() + .switchMap(activeKey => activeKey == null + ? O.empty() + : O.timer(ms, ms, scheduler)) + +// Start polling one full interval after a key becomes active. Changing keys +// resets the schedule, which keeps route-entry requests from being duplicated +// by an immediate timer emission. +export const pollWhileActive = ( + ms, + activeKey$, + scheduler, + hasFocus=() => document.hasFocus(), + enabled=process.browser +) => + enabled + ? schedulePollsWhileActive(ms, activeKey$, scheduler) + .filter(hasFocus) + : O.empty() + +export const pollWhileViewing = ( + ms, + views, + view$, + scheduler, + hasFocus, + enabled +) => { + const activeViews = Array.isArray(views) ? views : [ views ] + return pollWhileActive( + ms, + view$.map(shownView => activeViews.includes(shownView) ? shownView : null), + scheduler, + hasFocus, + enabled + ) +} const responseError = res => ({ status: res.status, diff --git a/test/app.test.js b/test/app.test.js index 50d85eb5..24b8cc7e 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -7,15 +7,30 @@ const { process.env.IS_ELEMENTS = "1"; process.env.MENU_ACTIVE = "Liquid"; +process.env.SHOW_PEG_DATA = "1"; +process.env.SHOW_HIGH_VALUE_ASSETS = "1"; +process.env.CANONICAL_URL = "https://blockstream.info/liquid/"; const { Observable: O } = require("../client/src/rxjs"); const { blockGridTransactionSelectEvent, + highValueAssetDefinitions, + pollIntervalsMs, } = require("../client/src/const"); +const { + pollWhileActive, + pollWhileViewing, + schedulePollsWhileActive, + tickWhileFocused, +} = require("../client/src/util"); const { default: main, + dashboardNewBlocks, + parseTipHeight, scheduleBlockTemplatePolls, scheduleDashboardBlockTemplatePolls, + subsequentTipHeights, + tipDrivenBlockListRefreshes, trackPendingBlockTemplateEvent, } = require("../client/src/app"); @@ -54,6 +69,23 @@ const makeBlockRoute = (hash) => { return route; }; +const makeApiRoute = () => { + const location = { + hash: "", + key: "api", + pathname: "/explorer-api", + query: {}, + }; + const location$ = O.of(location); + const route = (pattern) => + pattern === undefined || pattern === "/explorer-api" + ? location$ + : empty$; + + route.all$ = location$; + return route; +}; + const makeSources = ({ blockGridEvent$ = empty$, responseStreams = {}, @@ -87,6 +119,22 @@ const makeSources = ({ }, }); +const pollingOptions = (scheduler, hasFocus = () => true) => ({ + pollingEnabled: true, + pollingScheduler: scheduler, + hasFocus, +}); + +const requestFrames = (requests, category) => requests + .filter((request) => request.category === category) + .map((request) => request.frame); + +const highValueAssetRequestCount = (requests, frame) => requests.filter( + (request) => + request.frame === frame && + request.category.startsWith("dashboard-high-value-asset-"), +).length; + test("requests and consumes block templates on an Elements dashboard", () => { const selectedCategories = []; const sources = makeSources({ selectedCategories }); @@ -166,6 +214,293 @@ test("restarts template polling when the dashboard is reopened", () => { ]); }); +test("view polls wait one interval and reset when the active view changes", () => { + const scheduler = new TestScheduler((actual, expected) => + assert.deepEqual(actual, expected)); + const activeKey$ = new Subject(); + const polls = []; + + scheduler.maxFrames = 140_000; + schedulePollsWhileActive( + pollIntervalsMs.standard, + activeKey$, + scheduler, + ).subscribe(() => polls.push(scheduler.frame)); + scheduler.schedule(() => activeKey$.next("dashBoard"), 0); + scheduler.schedule(() => activeKey$.next("dashBoard"), 10_000); + scheduler.schedule(() => activeKey$.next("recentTxs"), 70_000); + scheduler.flush(); + + assert.deepEqual(polls, [60_000, 130_000]); +}); + +test("view polls pause while unfocused and restart after leaving the view", () => { + const scheduler = new TestScheduler((actual, expected) => + assert.deepEqual(actual, expected)); + const activeKey$ = new Subject(); + const polls = []; + let focused = true; + + scheduler.maxFrames = 270_000; + pollWhileActive( + pollIntervalsMs.standard, + activeKey$, + scheduler, + () => focused, + true, + ).subscribe(() => polls.push(scheduler.frame)); + scheduler.schedule(() => activeKey$.next("dashBoard"), 0); + scheduler.schedule(() => { focused = false; }, 50_000); + scheduler.schedule(() => { focused = true; }, 70_000); + scheduler.schedule(() => activeKey$.next(null), 130_000); + scheduler.schedule(() => activeKey$.next("dashBoard"), 200_000); + scheduler.flush(); + + assert.deepEqual(polls, [120_000, 260_000]); +}); + +test("global polling does not catch up immediately when focus returns", () => { + const scheduler = new TestScheduler((actual, expected) => + assert.deepEqual(actual, expected)); + const polls = []; + let focused = true; + + scheduler.maxFrames = 65_000; + tickWhileFocused( + pollIntervalsMs.fast, + scheduler, + () => focused, + true, + ).subscribe(() => polls.push(scheduler.frame)); + scheduler.schedule(() => { focused = false; }, 10_000); + scheduler.schedule(() => { focused = true; }, 35_000); + scheduler.flush(); + + assert.deepEqual(polls, [0, 60_000]); +}); + +test("view polls remain stopped on unrelated views", () => { + const scheduler = new TestScheduler((actual, expected) => + assert.deepEqual(actual, expected)); + const view$ = new Subject(); + const polls = []; + + scheduler.maxFrames = 140_000; + pollWhileViewing( + pollIntervalsMs.standard, + "dashBoard", + view$, + scheduler, + () => true, + true, + ).subscribe(() => polls.push(scheduler.frame)); + scheduler.schedule(() => view$.next("dashBoard"), 0); + scheduler.schedule(() => view$.next("tx"), 20_000); + scheduler.schedule(() => view$.next("dashBoard"), 70_000); + scheduler.flush(); + + assert.deepEqual(polls, [130_000]); +}); + +test("uses the configured dashboard request cadences without immediate duplicates", () => { + const scheduler = new TestScheduler((actual, expected) => + assert.deepEqual(actual, expected)); + const requests = []; + const expectedFrames = (interval) => { + const frames = []; + for (let frame = 0; frame <= pollIntervalsMs.slow; frame += interval) { + frames.push(frame); + } + return frames; + }; + + scheduler.maxFrames = pollIntervalsMs.slow; + main(makeSources(), pollingOptions(scheduler)).HTTP + .subscribe((request) => requests.push({ ...request, frame: scheduler.frame })); + scheduler.flush(); + + const fastFrames = expectedFrames(pollIntervalsMs.fast); + const standardFrames = expectedFrames(pollIntervalsMs.standard); + const slowFrames = expectedFrames(pollIntervalsMs.slow); + + assert.deepEqual(requestFrames(requests, "tip-height"), fastFrames); + assert.deepEqual(requestFrames(requests, "block-template"), fastFrames); + assert.deepEqual(requestFrames(requests, "recent"), fastFrames); + assert.deepEqual(requestFrames(requests, "mempool"), standardFrames); + assert.deepEqual(requestFrames(requests, "fee-est"), standardFrames); + assert.deepEqual( + requestFrames(requests, "dashboard-peg-mempool-txs"), + standardFrames, + ); + assert.deepEqual(requestFrames(requests, "blocks"), slowFrames); + assert.deepEqual( + requestFrames(requests, "bitcoin-market-chart"), + slowFrames, + ); + + const highValueAssetRequests = requests.filter((request) => + request.category.startsWith("dashboard-high-value-asset-")); + assert.deepEqual( + [...new Set(highValueAssetRequests.map((request) => request.frame))], + slowFrames, + ); + assert.equal( + highValueAssetRequests.length, + slowFrames.length * highValueAssetDefinitions.length * 2, + ); + for (const frame of slowFrames) { + assert.equal( + highValueAssetRequestCount(requests, frame), + highValueAssetDefinitions.length * 2, + ); + } +}); + +test("does not run the block-list reorg safety poll on unrelated routes", () => { + const scheduler = new TestScheduler((actual, expected) => + assert.deepEqual(actual, expected)); + const requests = []; + + scheduler.maxFrames = pollIntervalsMs.slow; + main( + makeSources({ route: makeApiRoute() }), + pollingOptions(scheduler), + ).HTTP.subscribe((request) => requests.push({ + ...request, + frame: scheduler.frame, + })); + scheduler.flush(); + + assert.deepEqual(requestFrames(requests, "blocks"), []); +}); + +test("emits only actual tip-height changes after the initial baseline", () => { + const tipHeight$ = new Subject(); + const heights = []; + + subsequentTipHeights(tipHeight$).subscribe((height) => heights.push(height)); + [ 100, 100, 101, 101, 99 ].forEach((height) => tipHeight$.next(height)); + + assert.deepEqual(heights, [ 101, 99 ]); +}); + +test("accepts only non-negative safe integer tip heights", () => { + assert.equal(parseTipHeight("0"), 0); + assert.equal(parseTipHeight(" 101\n"), 101); + assert.equal(parseTipHeight(42), 42); + + [ + "", + " ", + "1.5", + "-1", + "not-a-height", + -1, + 1.5, + Number.MAX_SAFE_INTEGER + 1, + Number.NaN, + Number.POSITIVE_INFINITY, + null, + undefined, + ].forEach((value) => assert.equal(parseTipHeight(value), null)); +}); + +test("gates tip-driven block refreshes to views that show the block list", () => { + const tipHeight$ = new Subject(); + const view$ = new Subject(); + const heights = []; + + tipDrivenBlockListRefreshes(tipHeight$, view$) + .subscribe((height) => heights.push(height)); + view$.next("dashBoard"); + tipHeight$.next(101); + view$.next("apiLanding"); + tipHeight$.next(102); + view$.next("recentBlocks"); + tipHeight$.next(103); + + assert.deepEqual(heights, [101, 103]); +}); + +test("refreshes the dashboard block list when the tip height changes", () => { + const tipHeightResponses = new Subject(); + const requests = []; + const sources = makeSources({ + responseStreams: { "tip-height": tipHeightResponses }, + }); + + main(sources).HTTP.subscribe((request) => requests.push(request)); + tipHeightResponses.next(O.of({ text: "not-a-height" })); + tipHeightResponses.next(O.of({ text: "100" })); + tipHeightResponses.next(O.of({ text: "" })); + tipHeightResponses.next(O.of({ text: "100" })); + tipHeightResponses.next(O.of({ text: "101" })); + + assert.equal( + requests.filter((request) => request.category === "blocks").length, + 2, + ); +}); + +test("treats the first block response of each dashboard visit as a baseline", () => { + const page$ = new Subject(); + const latestBlock$ = new Subject(); + const newBlocks = []; + + dashboardNewBlocks(page$, latestBlock$) + .subscribe((block) => newBlocks.push(block.id)); + + page$.next({ key: "home-1", pathname: "/" }); + latestBlock$.next({ id: "a" }); + latestBlock$.next({ id: "b" }); + page$.next({ key: "tx", pathname: "/tx/b" }); + latestBlock$.next({ id: "c" }); + page$.next({ key: "home-2", pathname: "/" }); + latestBlock$.next({ id: "d" }); + latestBlock$.next({ id: "e" }); + + assert.deepEqual(newBlocks, ["b", "e"]); +}); + +test("refreshes Liquid block-dependent data only after a new block response", () => { + const scheduler = new TestScheduler((actual, expected) => + assert.deepEqual(actual, expected)); + const blocksResponses = new Subject(); + const tipHeightResponses = new Subject(); + const requests = []; + const sources = makeSources({ + responseStreams: { + blocks: blocksResponses, + "tip-height": tipHeightResponses, + }, + }); + + scheduler.maxFrames = 16_000; + main(sources, pollingOptions(scheduler)).HTTP + .subscribe((request) => requests.push({ ...request, frame: scheduler.frame })); + scheduler.schedule(() => blocksResponses.next(O.of({ + body: [{ id: "a", height: 100 }], + })), 1); + scheduler.schedule(() => tipHeightResponses.next(O.of({ text: "100" })), 2); + scheduler.schedule(() => tipHeightResponses.next(O.of({ text: "101" })), 3); + scheduler.schedule(() => blocksResponses.next(O.of({ + body: [{ id: "b", height: 101 }], + })), 4); + scheduler.flush(); + + assert.deepEqual(requestFrames(requests, "blocks"), [0, 3]); + assert.deepEqual(requestFrames(requests, "dashboard-peg-asset"), [0, 4]); + assert.deepEqual( + requestFrames(requests, "dashboard-peg-chain-txs"), + [0, 4], + ); + assert.deepEqual( + requestFrames(requests, "dashboard-peg-mempool-txs"), + [0], + ); + assert.deepEqual(requestFrames(requests, "block-template"), [0, 15_004]); +}); + test("navigates a selected pending-block transaction in app history", () => { const txid = "a".repeat(64); const routeUpdates = [];