// Rendering a view the popup lands on without having navigated to it // forward: on restore, and on Back. In both cases the view may never have // been rendered in this page load — a reopened popup renders only the // wallet list and the view it restores onto, so every other view is still // the blank static template from index.html — so unhiding it is not enough. // // Forward navigation renders as it goes and must NOT come through here: // rendering a second time would re-fetch and clobber whatever the view has // in flight. // // The view modules are injected and nothing here touches the DOM, so the // dispatch and its data guards can be tested directly; src/popup/index.js // cannot be required outside a browser. const { RESTORABLE_VIEWS } = require("../shared/restorableViews"); // The views this page load has rendered. // // The Back path cannot otherwise tell its two cases apart. A view the popup // never rendered is still the blank template from index.html and has to be // rendered; a view already on the page must NOT be rendered again, because // a second render re-fetches and overwrites whatever the user has typed // into it and not yet saved. // // Registration is showView() in views/helpers.js, which is the last thing // every render path runs — restoreView()'s, the Back path's, and every // forward show(). That is the point of putting it there rather than in the // individual views: a view added later registers itself with no one having // to remember it, so this cannot decay. // // Module scope is page-load scope: the popup loads this module once per // page load, and a reopened popup gets a fresh, empty set — which is // exactly the state that makes the Back path render. const renderedViews = new Set(); function markViewRendered(view) { if (view) renderedViews.add(view); } // Begin a fresh page-load scope. The popup gets one by being loaded; the // unit tests, which simulate several page loads against one module // instance, ask for one. function resetRenderedViews() { renderedViews.clear(); } // Home is the exception: Back re-renders it every time, which is what the // popup did before this router existed (index.js registered // renderWalletList() as setRenderMain(), and goBack() called it on every // Back onto "main"). It must stay that way — the wallet list has to reflect // what changed while the user was away from it, such as a wallet renamed or // an address removed in Settings — and Home holds no unsaved input to lose. const ALWAYS_RENDER_ON_BACK = new Set(["main"]); // Views that render an address the user picked and cannot be rendered // without one. "confirm-tx" is here because its Sign button dereferences // `state.wallets[state.selectedWallet].encryptedSecret` // (src/popup/views/confirmTx.js) behind no guard of its own — a screen that // can only throw when the user presses its one button must not be restored // onto. const ADDRESS_VIEWS = new Set([ "address", "address-token", "receive", "transaction", "confirm-tx", ]); function needsAddress(view) { return ADDRESS_VIEWS.has(view); } function hasValidAddress(state) { return Boolean( state.selectedWallet !== null && state.selectedAddress !== null && state.wallets[state.selectedWallet] && state.wallets[state.selectedWallet].addresses[state.selectedAddress], ); } // The stored viewData ENTRIES each branch below dereferences, as opposed to // the one field it gates on. // // A gate on a single truthy field checks the container, not the entries, and // the dereference is one level below it: a stored `{"currentView": // "success-tx","viewData":{"hash":"0x1"}}` passes `data.hash` and then throws // on `address.toLowerCase()` inside addressTitle() (src/popup/views/ // helpers.js), out of restoreView(), which src/popup/index.js does not guard — // so the rest of popup init never runs. txStatus.restoreWait() has checked its // own branch's fields since it was written; these are the other four. // // Only what actually throws is required. Fields that are compared, // concatenated or escaped coerce (escapeHtml() and displaySymbol() both // String() their argument), so requiring them would refuse a restorable screen // over a cosmetic value. function isText(value) { return typeof value === "string"; } function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } // An address handed to renderAddressHtml()/addressTitle(): both reach // `address.slice()` and `address.toLowerCase()` with no guard. function isAddressText(value) { return isText(value); } // Decoded calldata, as decodedDetailsHtml() (src/popup/views/txStatus.js) // walks it: `for (const d of decoded.details)` needs an iterable, and each // entry's `address` reaches toAddressHtml(). Absent or falsy is the ordinary // case and short-circuits before either. function isRenderableDecoded(value) { if (!value) return true; if (!isRecord(value)) return false; if (!value.details) return true; if (!Array.isArray(value.details)) return false; return value.details.every( (entry) => isRecord(entry) && (!entry.address || isAddressText(entry.address)), ); } // The pending transaction confirmTx.show() renders: `token` reaches // renderAddressHtml() when it is not "ETH", and `from`/`to` reach // addressTitle(), makeBlockie() and getLocalWarnings(). function isRenderablePendingTx(value) { return ( isRecord(value) && isText(value.token) && isAddressText(value.from) && isAddressText(value.to) ); } // The stored transaction transactionDetail.render() shows. contractAddress is // optional on an ETH transfer, and reaches addressDotHtml() when it is there. function isRenderableTx(value) { return ( isRecord(value) && isAddressText(value.from) && isAddressText(value.to) && (!value.contractAddress || isAddressText(value.contractAddress)) ); } // Render `view` from persisted state. Each view module shows itself, so a // true return means the view is both rendered and on screen. // // Returns false when the view is not one the popup renders from state, or // when the state it would render is gone — a token no longer selected, a // transaction no longer persisted. The caller falls back rather than // putting an empty template on screen. function renderView(view, state, views) { if (!view || !RESTORABLE_VIEWS.has(view)) return false; if (needsAddress(view) && !hasValidAddress(state)) return false; if (view === "address-token" && !state.selectedToken) return false; const data = state.viewData || {}; switch (view) { case "main": views.main.show(); return true; case "address": views.addressDetail.show(); return true; case "address-token": views.addressToken.show(); return true; case "receive": views.receive.show(); return true; case "settings": views.settings.show(); return true; case "settings-addtoken": views.settingsAddToken.show(); return true; case "confirm-tx": if (!isRenderablePendingTx(data.pendingTx)) return false; views.confirmTx.restore(); return true; case "transaction": if (!isRenderableTx(data.tx)) return false; views.transactionDetail.render(); return true; case "wait-tx": // Resumes the receipt poll from the persisted broadcast time, // and answers false when there is nothing resumable left. return Boolean(views.txStatus.restoreWait()); case "success-tx": if (!data.hash) return false; if (!isAddressText(data.to)) return false; if (!isRenderableDecoded(data.decoded)) return false; views.txStatus.renderSuccess(); return true; case "error-tx": if (!data.message) return false; if (!isAddressText(data.to)) return false; views.txStatus.renderError(); return true; default: return false; } } // The Back-path renderer, registered with setBackRenderer() in // views/helpers.js. // // Returns false — leaving goBack() to unhide the view, as it always did — // in the two cases where the view is known to be on the page already: // // - It is not one the popup renders from persisted state. The restored // stack is filtered against RESTORABLE_VIEWS, so such a view can only // be on the stack from this page load, where forward navigation // rendered it on the way in. // - This page load has rendered it. Re-rendering would re-fetch and // clobber what it holds; Home is rendered anyway, see above. // // What is left is the case the router exists for: a view on the stack that // this page load has never rendered, whose template is still blank. function makeBackRenderer(state, views) { return function renderBack(view) { if (!RESTORABLE_VIEWS.has(view)) return false; if (renderedViews.has(view) && !ALWAYS_RENDER_ON_BACK.has(view)) { return false; } if (!renderView(view, state, views)) { views.main.show(); } return true; }; } module.exports = { renderView, makeBackRenderer, markViewRendered, resetRenderedViews, };