A catalog of the reusable primitives in the Credence frontend: React hooks in
src/hooks/ and framework-free helpers in src/lib/.
Reach for these before writing a new one — they encapsulate non-obvious behavior (focus management, SSR guards, request cancellation, on-chain-aligned penalty math) that is easy to get subtly wrong. Each entry lists the signature, parameters/return, behavior notes, and a minimal usage example linking to source.
SSR & cleanup conventions used below
- SSR-safe means the primitive does no
window/document/navigator/localStoragework during render — DOM access is deferred to an effect or guarded by atypeof window === 'undefined'check — so it is safe to import and call in server-rendered or test environments.- Cleanup notes call out listeners, subscriptions, in-flight requests, or timers that the primitive tears down on unmount/deactivate. Where a function hands you a teardown handle, you own calling it.
Source: src/hooks/useFocusTrap.ts · Companion spec: focus-patterns.md
function useFocusTrap(options: UseFocusTrapOptions): void
interface UseFocusTrapOptions {
containerRef: RefObject<HTMLElement | null>
isActive: boolean
initialFocusRef?: RefObject<HTMLElement | null>
returnFocusRef?: RefObject<HTMLElement | null>
onEscape?: () => void
returnFocusOnDeactivate?: boolean // default: true
}Constrains keyboard focus to a container while active and restores it on deactivate — the primitive behind modals, dialogs, and full-screen overlays.
Parameters
| Option | Required | Description |
|---|---|---|
containerRef |
✓ | Element whose focusable descendants are trapped. No-op until current is set. |
isActive |
✓ | Engage (true) / disengage (false) the trap; drives initial-focus and return-focus lifecycle. |
initialFocusRef |
Element to focus on activation. Falls back to the first focusable element in the container. | |
returnFocusRef |
Element to focus on deactivation. Falls back to whatever was focused before activation. | |
onEscape |
Called on Escape (after preventDefault). You close the overlay (e.g. flip isActive). |
|
returnFocusOnDeactivate |
When false, do not restore focus on deactivate. Default true. |
Behavior notes
- Fresh querying: focusable elements are recomputed on every
Tabpress, not cached at activation — so controls that mount/unmount or toggledisabled/visibility while the trap is open are handled correctly. requestAnimationFramefocus: both the initial focus and the return focus are applied on the next animation frame, letting the overlay paint (or unmount) first so the target is actually focusable.- Wrapping:
Tabon the last element wraps to the first;Shift+Tabon the first (or when focus escaped the container) wraps to the last. - Visibility filter: only visible elements (
offsetParent/getClientRects) are considered; hidden inputs andtabindex="-1"are excluded. - Edge cases: a container with no focusable elements traps nothing; a return target that was removed from the DOM is safely skipped.
- SSR-safe / cleanup: all DOM work runs inside
useEffect; thekeydownlistener is always removed on deactivate/unmount.
import { useRef } from 'react'
import { useFocusTrap } from '../hooks/useFocusTrap'
function ConfirmDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const dialogRef = useRef<HTMLDivElement>(null)
const confirmRef = useRef<HTMLButtonElement>(null)
useFocusTrap({
containerRef: dialogRef,
isActive: open,
initialFocusRef: confirmRef, // focus the primary action on open
onEscape: onClose, // Escape requests a close
})
if (!open) return null
return (
<div ref={dialogRef} role="dialog" aria-modal="true">
<button onClick={onClose}>Cancel</button>
<button ref={confirmRef} onClick={onClose}>
Confirm
</button>
</div>
)
}Source: src/hooks/useDocumentTitle.ts
function useDocumentTitle(title: string, options?: UseDocumentTitleOptions): void
interface UseDocumentTitleOptions {
brandSuffix?: boolean // default: true — append ` · Credence`
restoreOnUnmount?: boolean // default: true — restore prior title on unmount
}
// Also exported: formatDocumentTitle(title, brandSuffix?), BRAND, BRAND_SEPARATOR, BRAND_SUFFIXSets document.title to a descriptive, branded title for the component's lifetime. Keeping
the title in sync with the route is an accessibility win — screen readers announce it on
navigation, and tabs/history/bookmarks become distinguishable.
Behavior notes
- Never double-applies the
· Credencesuffix; an empty title resolves to justCredence. formatDocumentTitleis exported for testing/pre-computing titles without the effect.- SSR-safe / cleanup: guarded by
typeof document === 'undefined'; runs in an effect and restores the previous title on unmount (unlessrestoreOnUnmount: false).
import { useDocumentTitle } from '../hooks/useDocumentTitle'
function Bond() {
useDocumentTitle('Bond') // document.title === 'Bond · Credence'
return <main>…</main>
}Source: src/hooks/useMediaQuery.ts
function useMediaQuery(query: string): boolean
function useIsMobile(): boolean // shorthand: (max-width: 767px)Subscribes to a CSS media query and returns whether it currently matches. The exported
breakpoint helper useIsMobile wraps the 768 px mobile threshold used throughout the app.
Parameters
| Parameter | Required | Description |
|---|---|---|
query |
✓ | A valid CSS media query string, e.g. '(max-width: 767px)'. |
Behavior notes
- Returns
falseduring SSR or whenwindow.matchMediais unavailable — no crash. - Uses a lazy
useStateinitializer to read the initial match synchronously on first render, eliminating any flash of wrong state. - Subscribes via
addEventListener('change', …)with optional chaining, mirroring the pattern inSettingsContext. - SSR-safe / cleanup: all DOM work is guarded; the
changelistener is removed on unmount or when the query string changes.
import { useMediaQuery, useIsMobile } from '../hooks/useMediaQuery'
// Generic usage
const isWide = useMediaQuery('(min-width: 1024px)')
// Breakpoint helper
function ActivityCard() {
const isMobile = useIsMobile()
return <h2>{isMobile ? 'Recent Activity' : 'Recent Activity Timeline'}</h2>
}Source: src/hooks/useQuery.ts
function useQuery<T>(
queryFn: () => Promise<T>,
options?: UseQueryOptions,
): UseQueryResult<T>
interface UseQueryOptions {
enabled?: boolean // default: true
}
interface UseQueryResult<T> {
data: T | undefined
isLoading: boolean
error: Error | null
refetch: () => Promise<void>
}A custom hook that wraps an asynchronous query function to fetch and manage data state.
Parameters
| Option | Required | Description |
|---|---|---|
queryFn |
✓ | An asynchronous function returning a Promise. |
enabled |
Set to false to prevent the initial request. Default true. |
Behavior notes
- Offline-safe: both the initial query execution and subsequent
refetchare disabled when offline (usingwindow.navigator.onLine). - Safe state updates: uses component lifecycle checks to safely ignore state updates if the component unmounts before the asynchronous query finishes.
- Race-condition protection: uses run IDs to guarantee that only the latest triggered fetch updates the component state.
- SSR-safe / cleanup: all DOM/navigator checks are guarded for server-rendered environments; active promises do not trigger state updates on unmount.
import { useQuery } from '../hooks/useQuery'
import { apiFetch } from '../api/client'
function MyComponent() {
const { data, isLoading, refetch } = useQuery(() => apiFetch('/data'))
return (
<div>
<button onClick={refetch} disabled={isLoading}>Refresh</button>
{isLoading ? <p>Loading...</p> : <p>Data: {JSON.stringify(data)}</p>}
</div>
)
}Source: src/hooks/useReducedMotion.ts · See also: motion-guidelines.md
function useReducedMotion(): booleanReturns true when the user has prefers-reduced-motion: reduce set, and stays in sync as
the OS preference changes. Gate or shorten animations on this value.
Behavior notes
- Subscribes to the
matchMediachange event, with anaddListener/removeListenerfallback for legacy browsers; re-syncs once on mount in case the preference changed before subscribing. - SSR-safe / cleanup: returns
falsewhenwindow/matchMediais unavailable; removes its media-query listener on unmount.
import { useReducedMotion } from '../hooks/useReducedMotion'
function Banner() {
const reduceMotion = useReducedMotion()
return <div className={reduceMotion ? 'no-anim' : 'slide-in'}>…</div>
}Source: src/hooks/useReducedTransparency.ts · See also: ACCESSIBILITY.md
function useReducedTransparency(): booleanReturns true when the user has prefers-reduced-transparency: reduce set, and stays in
sync as the OS preference changes. When true, any component that sets inline transparent
backgrounds (e.g. rgba() values) should fall back to a fully-opaque equivalent so that
content behind an overlay does not bleed through.
Behavior notes
- Identical subscription pattern to
useReducedMotion: subscribes tomatchMedia, with anaddListener/removeListenerfallback; re-syncs on mount. - CSS-first: components that express backdrop colours via the
--credence-backdrop-light,--credence-backdrop-dark, or--credence-backdrop-mobiletokens do not need this hook — the global@media (prefers-reduced-transparency: reduce)block insrc/index.cssoverrides those tokens automatically. Reach for the hook only when transparency is applied via a JS inline style. - SSR-safe / cleanup: returns
falsewhenwindow/matchMediais unavailable; removes its media-query listener on unmount.
import { useReducedTransparency } from '../hooks/useReducedTransparency'
function GlassPanel({ children }: { children: React.ReactNode }) {
const reduceTransparency = useReducedTransparency()
return (
<div
style={{
background: reduceTransparency
? 'var(--credence-surface-card)'
: 'rgba(255, 255, 255, 0.6)',
}}
>
{children}
</div>
)
}Source: src/hooks/useScrollPreserver.ts
function useScrollPreserver(options: UseScrollPreserverOptions): void
interface UseScrollPreserverOptions {
isActive: boolean
}Preserves the page's scroll position and prevents content reflow when an overlay (drawer, modal, dialog) opens and locks body scroll. Without this hook, setting overflow: hidden on the body causes the scrollbar to disappear, which shifts the content horizontally by the scrollbar width — a jarring visual jump for users on long-scrolling pages.
Behavior notes
- On activation (
isActive → true): saves the currentwindow.scrollYand measures the scrollbar width (window.innerWidth - document.documentElement.clientWidth). Setsoverflow: hiddenondocument.bodyto lock background scrolling, and addspadding-rightequal to the scrollbar width to prevent horizontal reflow. - On deactivation (
isActive → false/ unmount): restores the previousoverflowandpadding-rightvalues, then callswindow.scrollTo(0, savedScrollY)to return to the exact scroll position the user was at before the overlay opened. - No-op when inactive: when
isActiveisfalse, the hook does nothing — no DOM mutations, no side effects. - Scrollbar‑width aware: if the scrollbar width is zero (e.g. overlay scrollbars or a non-scrolling page), no padding is added. The saved
padding-rightis always restored exactly. - SSR-safe / cleanup: all DOM work runs inside
useEffect; the effect's cleanup function restores overflow, padding, and scroll position on unmount or whenisActiveflips tofalse.
import { useState } from 'react'
import { useScrollPreserver } from '../hooks/useScrollPreserver'
function MyDrawer() {
const [open, setOpen] = useState(false)
useScrollPreserver({ isActive: open })
return (
<>
<button onClick={() => setOpen(true)}>Open drawer</button>
{open && <div className="drawer">{/* overlay content */}</div>}
</>
)
}Source: src/hooks/useScrollToTop.ts
function useScrollToTop(): booleanReturns true when the page has been scrolled more than BACK_TO_TOP_SCROLL_THRESHOLD (800 px) from the top, and false otherwise. Used by BackToTop to decide when to render the affordance.
Exported constant: BACK_TO_TOP_SCROLL_THRESHOLD = 800 — the pixel threshold at which the button becomes visible.
Cleanup: removes the passive scroll listener on unmount.
import { useScrollToTop } from '../hooks/useScrollToTop'
function MyComponent() {
const showButton = useScrollToTop()
return showButton ? <button>↑</button> : null
}Source: src/hooks/useTrustScore.ts
function useTrustScore(address: string): UseTrustScoreResult
interface UseTrustScoreResult {
data: TrustScore | null
isLoading: boolean
error: ApiError | null
refetch: () => void
}Loads trust-score data for a Stellar public key from the Credence API
(GET /trust-score/:address).
Behavior notes
- Manual / lazy: does not fetch on mount or on
addresschange — callrefetch()(e.g. after the user submits a lookup). Invalid/empty addresses (perisValidStellarAddress) are silently ignored. - Race-safe: in-flight requests are aborted when
refetchis called again or the hook unmounts; stale responses andAbortErrors are discarded so only the latest result wins. - SSR-safe / cleanup: no DOM/
windowaccess; the activeAbortControlleris aborted on unmount.
import { useState } from 'react'
import { useTrustScore } from '../hooks/useTrustScore'
function Lookup() {
const [address, setAddress] = useState('')
const { data, isLoading, error, refetch } = useTrustScore(address)
return (
<form
onSubmit={(e) => {
e.preventDefault()
refetch()
}}
>
<input value={address} onChange={(e) => setAddress(e.target.value)} />
<button disabled={isLoading}>Look up</button>
{error && <p role="alert">{error.message}</p>}
{data && <p>Score: {data.score}</p>}
</form>
)
}Source: src/hooks/useWallet.ts · Built on freighterClient
function useWallet(settingsNetwork: string): UseWalletState
interface UseWalletState {
address: string
isConnected: boolean
isConnecting: boolean
error: WalletError | null
connect: () => Promise<void>
disconnect: () => void
network: CredenceNetwork
}Manages Freighter wallet connection state for the dApp. Pass the network selected in
SettingsContext ('public' or 'test'; anything else is treated as 'public').
Behavior notes
- Wraps every Freighter call in browser guards and surfaces failures as a typed
WalletError(not_installed|rejected|network_mismatch|unknown) instead of throwing. - Attempts a silent session restore on mount (if access was previously granted) and watches for account changes via a Freighter watcher; a network mismatch between Freighter and Settings is reported as an error.
- SSR-safe / cleanup:
connectand the restore effect early-return whenwindowis undefined; the wallet-change watcher is stopped ondisconnectand on unmount.
import { useWallet } from '../hooks/useWallet'
function ConnectButton({ network }: { network: string }) {
const { isConnected, isConnecting, address, connect, disconnect, error } = useWallet(network)
if (isConnected) return <button onClick={disconnect}>{address.slice(0, 6)}… ✕</button>
return (
<>
<button onClick={connect} disabled={isConnecting}>
{isConnecting ? 'Connecting…' : 'Connect wallet'}
</button>
{error && <p role="alert">{error.message}</p>}
</>
)
}Source: src/hooks/useUsdcBalance.ts · Built on horizon
function useUsdcBalance(): UseUsdcBalanceResult
type UseUsdcBalanceStatus = 'idle' | 'loading' | 'ready' | 'error'
interface UseUsdcBalanceResult {
balance: number
status: UseUsdcBalanceStatus
error: Error | null
refetch: () => void
}Fetches the connected account's USDC balance from the Stellar Horizon API. Reads the
wallet address from useWallet() and the active network from useSettings().
Behavior notes
- Auto-fetches on mount when a wallet is connected, and re-fetches whenever the connected address or active network changes.
- Returns
{ balance: 0, status: 'idle' }when no wallet is connected — does not attempt a Horizon request. - Race-safe: in-flight requests are aborted when
refetchis called again, when address/network changes, or on unmount. Stale responses andAbortErrors are discarded. - Returns
0balance when the account has no USDC trustline (asset not found on Horizon). - SSR-safe / cleanup: no DOM access during render; the active
AbortControlleris aborted on unmount.
import { useUsdcBalance } from '../hooks/useUsdcBalance'
import { formatUsdc } from '@/lib/format'
function BalanceDisplay() {
const { balance, status, error, refetch } = useUsdcBalance()
if (status === 'idle') return <p>Connect wallet to see balance</p>
if (status === 'loading') return <p>Loading balance…</p>
if (status === 'error') {
return (
<p role="alert">
Could not load balance. <button onClick={refetch}>Retry</button>
</p>
)
}
return <p>Available: {formatUsdc(balance)}</p>
}Source: src/hooks/useProductUpdates.ts
function useProductUpdates(): UseProductUpdatesResult
interface UseProductUpdatesResult {
updates: readonly ProductUpdate[]
unreadCount: number
isLoading: boolean
error: string | null
markAllRead: () => void
refetch: () => Promise<void>
}- Sourced asynchronously from JSON feed (
CHANGELOG_FEED_URL=/changelog.json) with fallback to static updates if offline or fetch fails. - Persists read state in
localStorageunderCHANGELOG_STORAGE_KEY(credence:last-seen-update-id). - Calculates
unreadCountbased on items newer than the stored last-seen update ID.
import { useProductUpdates } from '../hooks/useProductUpdates'
function NotificationBadge() {
const { unreadCount, markAllRead } = useProductUpdates()
return (
<button onClick={markAllRead}>
Updates {unreadCount > 0 && <span>({unreadCount})</span>}
</button>
)
}Source: src/hooks/useSmartBack.ts · Pure utility: src/lib/smartBack.ts
function useSmartBack(options?: UseSmartBackOptions): UseSmartBackReturn
interface UseSmartBackOptions {
fallback?: string // default: '/dashboard'
}
interface UseSmartBackReturn {
goBack: () => void
fallback: string
getDestination: () => SmartBackResult
}Smart back navigation hook that honours prior route state when navigating back and safely falls back to /dashboard (or a custom route) when history is missing.
Behavior notes
- Prior route priority: if
location.state.fromis present,goBack()navigates directly to that path. - History back: if
fromstate is absent and browser history is available (window.history.length > 1), callsnavigate(-1). - Missing history fallback: if
fromstate is absent and history is empty (e.g. direct deep link landing), navigates to/dashboard.
import { useSmartBack } from '../hooks/useSmartBack'
function BackButton() {
const { goBack } = useSmartBack({ fallback: '/dashboard' })
return (
<button onClick={goBack} aria-label="Go back">
← Back
</button>
)
}Framework-free helpers — pure functions and a wallet SDK wrapper. No React required.
Source: src/lib/format.ts · Single source of truth for USDC display.
Canonical rule (closes #558): Always use
formatUsdc(amount)to display a USDC amount in the UI. Never inlineamount.toLocaleString('en-US') + ' USDC',amount.toFixed(2) + ' USDC', or any other ad-hoc pattern. All monetary display helpers live informat.tsso every surface shows consistent numbers.
formatUsdc(amount: number): string // 1234.5 → "1,234.5 USDC"
normalizeUSDC(rawValue: string): string // "1,234.5" → "1234.50" (clamps <0 to "0.00"; invalid → "")
formatUSDC(rawValue: string): string // "1234.5" → "1,234.50" (invalid → unchanged)
formatUSDCDisplay(rawValue: string): string // alias of formatUSDC, for UI contexts
sanitizeUSDCInput(nextValue: string): string // "$1,000.50" → "1000.50" (≤2 decimals, strips junk)Behavior notes: all helpers use the en-US locale for locale-independent separators;
empty input yields ""; normalizeUSDC clamps negatives to 0 and rejects non-numeric
input, while formatUSDC/formatUSDCDisplay return the original text unchanged so the user
can correct it. SSR-safe (pure functions, no globals).
import { formatUsdc, sanitizeUSDCInput } from '@/lib/format'
// ✅ Correct — always use formatUsdc for display
formatUsdc(1000) // "1,000 USDC"
formatUsdc(1234.5) // "1,234.5 USDC"
formatUsdc(Number(str)) // when amount comes from a string state value
// ❌ Avoid — do not use ad-hoc patterns
// `${amount.toLocaleString('en-US')} USDC`
// `${amount.toFixed(2)} USDC`
sanitizeUSDCInput('12.345') // "12.34"Source: src/lib/stellar.ts · Single source of truth for address handling.
isValidStellarAddress(address: string | undefined | null): boolean
truncateAddress(address: string | undefined | null): stringBehavior notes: validation requires exactly 56 uppercase-alphanumeric characters starting
with G. truncateAddress shows first 12 … last 8 for long addresses, leaves anything
≤ 20 chars untouched, trims whitespace, and returns "" for nullish/empty input. SSR-safe.
import { isValidStellarAddress, truncateAddress } from '@/lib/stellar'
isValidStellarAddress('GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNA') // true
truncateAddress('GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNA') // "GAAZI4TCR3TY...CCWNA"Source: src/lib/tier.ts · Aligned to tier-thresholds.md.
type TrustTier = 'bronze' | 'silver' | 'gold' | 'platinum'
const TIER_THRESHOLDS // inclusive ranges; platinum.max === null (no cap)
tierForScore(score: number): TrustTierBehavior notes: tierForScore maps a 0–1000 score to its tier, clamping negatives to
bronze and anything at/above 750 to platinum. TIER_THRESHOLDS is the single source of
truth for the numeric ranges. SSR-safe.
import { tierForScore } from '@/lib/tier'
tierForScore(300) // "silver"
tierForScore(900) // "platinum"Source: src/lib/bondPenalty.ts · Rates mirror on-chain policy.
type BondDurationDays = 30 | 90 | 180
getPenaltyRateForDuration(durationDays: number): number // 30→0.2, 90→0.15, 180→0.1; unknown→0.2
computeBondSlashBreakdown(amountUsdc: number, durationDays: number): BondSlashBreakdownBehavior notes: used in the CreateBondFlow review step to preview the cost of early exit for a prospective bond. Unknown durations fall back to the most conservative (highest) rate. The breakdown returns both formatted strings and raw USDC numbers. SSR-safe.
import { computeBondSlashBreakdown } from '@/lib/bondPenalty'
computeBondSlashBreakdown(1000, 30)
// → { penaltyPercent: 20, penaltyAmount: '200 USDC', resultingBalance: '800 USDC', … }Source: src/lib/penalty.ts
type BondStatus = 'active' | 'locked' | 'grace-period'
interface MockBond { id: number; amountUsdc: number; status: BondStatus }
getPenaltyRate(status: BondStatus): number // locked→0.2, grace-period→0.1, active/other→0
computeWithdrawBreakdown(bond: MockBond): ConfirmDialogPenaltyBreakdown & { penaltyUsdc: number }Behavior notes: the status-based counterpart to bondPenalty — computes the penalty for
an existing bond from its lifecycle status, ready to feed the withdrawal ConfirmDialog on
Bond.tsx. SSR-safe.
import { computeWithdrawBreakdown } from '@/lib/penalty'
computeWithdrawBreakdown({ id: 1, amountUsdc: 1000, status: 'locked' })
// → { bondAmount: '1,000 USDC', penaltyPercent: 20, penaltyAmount: '200 USDC', resultingBalance: '800 USDC', penaltyUsdc: 200 }Source: src/lib/freighterClient.ts · Prefer useWallet in components.
const FREIGHTER_INSTALL_URL: string
type CredenceNetwork = 'public' | 'test'
mapFreighterNetwork(freighterNetwork: string): CredenceNetwork | null
checkFreighterInstalled(): Promise<boolean>
requestFreighterAccess(): Promise<{ ok: true; address: string } | { ok: false; code; message }>
fetchFreighterAddress(): Promise<string | null> // silent, no prompt
fetchFreighterNetwork(): Promise<CredenceNetwork | null>
createWalletWatcher(onChange): Promise<{ stop: () => void } | null>
resetFreighterModuleCache(): void // tests onlyBehavior notes: an SSR-safe, lazy-loading wrapper around @stellar/freighter-api.
Importing the module has no side effects; the SDK is imported on first use and cached.
Functions return null/failure results outside a browser instead of throwing.
- Cleanup:
createWalletWatcherreturns a{ stop }handle — callstop()to remove the subscription (e.g. on unmount).useWalletdoes this for you.
import { createWalletWatcher } from '@/lib/freighterClient'
const watcher = await createWalletWatcher(({ address }) => console.log('now:', address))
// later…
watcher?.stop()Source: src/lib/horizon.ts · Used by useUsdcBalance.
class HorizonError extends Error {
readonly status: number
}
fetchUsdcBalance(
address: string,
network: CredenceNetwork,
signal?: AbortSignal
): Promise<number>Behavior notes: a lightweight, SSR-safe wrapper around the Stellar Horizon REST API.
Fetches the USDC balance for a given public key from the correct Horizon server
(horizon.stellar.org for public, horizon-testnet.stellar.org for test). Returns 0
when the account has no USDC trustline or the account doesn't exist (404). Throws
HorizonError with the HTTP status on other failures. Accepts an optional AbortSignal
for cancellation.
import { fetchUsdcBalance, HorizonError } from '@/lib/horizon'
try {
const balance = await fetchUsdcBalance('G…', 'public')
console.log(`USDC balance: ${balance}`)
} catch (err) {
if (err instanceof HorizonError) {
console.error(`Horizon error ${err.status}: ${err.message}`)
}
}Source: src/lib/safeOpenExternal.ts · Defence-in-depth security utility.
type SafeOpenError =
| { kind: 'blocked_protocol'; url: string; protocol: string }
| { kind: 'invalid_url'; url: string }
type SafeOpenResult =
| { ok: true; handle: WindowProxy | null }
| { ok: false; error: SafeOpenError }
safeOpenExternal(url: string, features?: string): SafeOpenResultThreat model: without this wrapper, passing a javascript: URI to window.open
executes arbitrary script in the opener's context, enabling credential theft or DOM
manipulation. Missing noopener on new windows also permits reverse tabnapping — the opened
tab holds a window.opener reference it can use to navigate the parent page.
Behavior notes:
- Protocol allowlist — only
https:,http:, andmailto:are accepted. Any other scheme (javascript:,data:,vbscript:,blob:, etc.) is rejected with a typed error beforewindow.openis called. Always opens target_blank. - Forced
noopener,noreferrer— injected into the feature string unconditionally, matching therelattributes on every<a target="_blank">in the codebase. Duplicates are de-duped. - Never throws — failures are returned as a typed discriminated union; callers do not need
try/catch. - Not SSR-safe (calls
window.open); use only in browser event handlers.
import { safeOpenExternal } from '@/lib/safeOpenExternal'
const result = safeOpenExternal('https://stellar.expert/explorer/public/tx/abc123')
if (!result.ok) {
console.error('Blocked:', result.error.kind, result.error.url)
}When you add a hook to src/hooks/ or a utility to src/lib/:
- Write complete TSDoc on the export(s) — every parameter, the return, non-obvious
behavior, and SSR-safety/cleanup expectations where the primitive touches
window/document/navigator/localStorageor sets up subscriptions. - Add an entry here following the same shape (signature → params/return → behavior notes → usage example linking to source). Keep it accurate to the real signature — no aspirational APIs.
- Add the entry to the Contents list above.