diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx
index 770bee7..759c6c0 100644
--- a/src/context/AppContext.jsx
+++ b/src/context/AppContext.jsx
@@ -1,10 +1,15 @@
import { createContext, useContext, useState, useCallback, useEffect, useMemo, useRef } from 'react'
-import { fetchOrg, fetchRepos, fetchContributors, fetchIssues, fetchRateLimit, fetchPulls } from '../services/github'
+import { fetchOrg, fetchRepos, fetchContributors, fetchIssues, fetchRateLimit, fetchPulls, bumpPatGeneration, asValidRateLimit } from '../services/github'
import { buildAnalyticalModel, getTopRepositories } from '../services/analytics'
import { saveAnalysis, loadAnalysis } from '../services/cache'
const Ctx = createContext(null)
+function isStaleRateLimitIncrease(current, next) {
+ if (!current || next.limit !== current.limit || next.remaining <= current.remaining) return false
+ return current.reset > 0 && Date.now() < current.reset * 1000
+}
+
function getStoredRateLimit() {
const stored = localStorage.getItem('oe_rate_limit')
@@ -32,6 +37,8 @@ export function AppProvider({ children }) {
const [issuesData, setIssuesData] = useState({})
const [pullsData, setPullsData] = useState({})
const [rateLimit, setRateLimit] = useState(getStoredRateLimit)
+ const rateLimitRef = useRef(rateLimit)
+ const patGenerationRef = useRef(0)
const [loading, setLoading] = useState(false)
const [loadMsg, setLoadMsg] = useState('')
const [govLoading, setGovLoading] = useState(false)
@@ -100,8 +107,12 @@ export function AppProvider({ children }) {
useEffect(() => {
const handler = e => {
- setRateLimit(e.detail)
- localStorage.setItem('oe_rate_limit', JSON.stringify(e.detail))
+ const normalized = asValidRateLimit(e.detail)
+ if (!normalized) return
+ if (isStaleRateLimitIncrease(rateLimitRef.current, normalized)) return
+ rateLimitRef.current = normalized
+ setRateLimit(normalized)
+ localStorage.setItem('oe_rate_limit', JSON.stringify(normalized))
}
window.addEventListener('rate-limit-update', handler)
@@ -115,6 +126,7 @@ export function AppProvider({ children }) {
if (!rateLimit?.reset) return
const timeout = setTimeout(() => {
+ rateLimitRef.current = null
localStorage.removeItem('oe_rate_limit')
setRateLimit(null)
}, Math.max(0, rateLimit.reset * 1000 - Date.now()))
@@ -123,16 +135,29 @@ export function AppProvider({ children }) {
}, [rateLimit])
const refreshRateLimit = useCallback(async () => {
+ const generation = patGenerationRef.current
const rl = await fetchRateLimit(pat)
- if (rl) {
- setRateLimit(rl)
- return true
- }
- return false
+ if (!rl) return false
+ if (generation !== patGenerationRef.current) return 'superseded'
+ if ((pat || '') !== (localStorage.getItem('oe_pat') || '')) return 'superseded'
+ // `/rate_limit` can lag behind live response headers. Ignore stale
+ // increases while the current window is still active.
+ if (isStaleRateLimitIncrease(rateLimitRef.current, rl)) return true
+ rateLimitRef.current = rl
+ setRateLimit(rl)
+ localStorage.setItem('oe_rate_limit', JSON.stringify(rl))
+ return true
}, [pat])
const savePat = useCallback(token => {
+ patGenerationRef.current += 1
+ bumpPatGeneration()
setPat(token)
token ? localStorage.setItem('oe_pat', token) : localStorage.removeItem('oe_pat')
+ // Old quota belongs to the old PAT. Clear it so the new PAT's higher
+ // remaining count is accepted instead of being blocked as stale.
+ rateLimitRef.current = null
+ localStorage.removeItem('oe_rate_limit')
+ setRateLimit(null)
}, [])
// Multi-org explore
diff --git a/src/context/AppContext.test.jsx b/src/context/AppContext.test.jsx
new file mode 100644
index 0000000..2f53e66
--- /dev/null
+++ b/src/context/AppContext.test.jsx
@@ -0,0 +1,148 @@
+import { act, render, screen } from '@testing-library/react'
+import { useState } from 'react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { AppProvider, useApp } from './AppContext'
+
+const { loadAnalysis, saveAnalysis, fetchRateLimit, bumpPatGeneration } = vi.hoisted(() => ({
+ loadAnalysis: vi.fn(),
+ saveAnalysis: vi.fn(),
+ fetchRateLimit: vi.fn(),
+ bumpPatGeneration: vi.fn(),
+}))
+
+vi.mock('../services/cache', () => ({ loadAnalysis, saveAnalysis }))
+vi.mock('../services/github', async importOriginal => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ fetchOrg: vi.fn(),
+ fetchRepos: vi.fn(),
+ fetchContributors: vi.fn(),
+ fetchIssues: vi.fn(),
+ fetchPulls: vi.fn(),
+ fetchRateLimit,
+ bumpPatGeneration,
+ }
+})
+
+function RateLimitProbe() {
+ const { rateLimit, savePat, refreshRateLimit } = useApp()
+ const [refreshResult, setRefreshResult] = useState('')
+ return (
+ <>
+
+
+
+
+ >
+ )
+}
+
+function dispatchRateLimit(detail) {
+ act(() => {
+ window.dispatchEvent(new CustomEvent('rate-limit-update', { detail }))
+ })
+}
+
+describe('AppContext rate-limit events', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date('2026-10-05T12:00:00Z'))
+ localStorage.clear()
+ loadAnalysis.mockResolvedValue(null)
+ saveAnalysis.mockReset()
+ fetchRateLimit.mockReset()
+ bumpPatGeneration.mockReset()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ localStorage.clear()
+ })
+
+ it('rejects same-window increases but accepts decreases and next-window updates', () => {
+ const reset = Math.floor(Date.now() / 1000) + 60
+ const current = { limit: 5000, remaining: 4200, used: 800, reset }
+ localStorage.setItem('oe_rate_limit', JSON.stringify(current))
+
+ render(
+
+
+
+ )
+
+ dispatchRateLimit({ ...current, remaining: 5000, used: 0 })
+ expect(screen.getByTestId('rate-limit').textContent).toBe(JSON.stringify(current))
+ expect(JSON.parse(localStorage.getItem('oe_rate_limit'))).toEqual(current)
+
+ const decreased = { ...current, remaining: 4199, used: 801 }
+ dispatchRateLimit(decreased)
+ expect(screen.getByTestId('rate-limit').textContent).toBe(JSON.stringify(decreased))
+
+ vi.setSystemTime(new Date((reset + 1) * 1000))
+ const nextWindow = { ...current, remaining: 5000, used: 0, reset: reset + 3600 }
+ dispatchRateLimit(nextWindow)
+ expect(screen.getByTestId('rate-limit').textContent).toBe(JSON.stringify(nextWindow))
+ })
+
+ it('ignores invalid rate-limit events', () => {
+ const current = { limit: 5000, remaining: 4200, used: 800, reset: 1_800_000_000 }
+ localStorage.setItem('oe_rate_limit', JSON.stringify(current))
+
+ render(
+
+
+
+ )
+
+ dispatchRateLimit({ ...current, limit: 'invalid' })
+
+ expect(screen.getByTestId('rate-limit').textContent).toBe(JSON.stringify(current))
+ expect(JSON.parse(localStorage.getItem('oe_rate_limit'))).toEqual(current)
+ })
+
+ it('clears the displayed and stored quota when a PAT is saved', () => {
+ const current = { limit: 5000, remaining: 4200, used: 800, reset: 1_800_000_000 }
+ localStorage.setItem('oe_pat', 'old-token')
+ localStorage.setItem('oe_rate_limit', JSON.stringify(current))
+
+ render(
+
+
+
+ )
+
+ act(() => screen.getByRole('button', { name: 'Save PAT' }).click())
+
+ expect(screen.getByTestId('rate-limit').textContent).toBe('null')
+ expect(localStorage.getItem('oe_pat')).toBe('new-token')
+ expect(localStorage.getItem('oe_rate_limit')).toBeNull()
+ expect(bumpPatGeneration).toHaveBeenCalledOnce()
+ })
+
+ it('marks a refresh as superseded when the stored PAT changes mid-request', async () => {
+ let resolveRefresh
+ localStorage.setItem('oe_pat', 'old-token')
+ fetchRateLimit.mockImplementation(
+ () => new Promise(resolve => { resolveRefresh = resolve })
+ )
+
+ render(
+
+
+
+ )
+
+ act(() => screen.getByRole('button', { name: 'Refresh quota' }).click())
+ localStorage.setItem('oe_pat', 'new-token')
+ await act(async () => {
+ resolveRefresh({ limit: 5000, remaining: 4000, used: 1000, reset: 1_800_000_000 })
+ await Promise.resolve()
+ })
+
+ expect(screen.getByTestId('refresh-result').textContent).toBe('superseded')
+ expect(localStorage.getItem('oe_rate_limit')).toBeNull()
+ })
+})
diff --git a/src/pages/SettingsPage.jsx b/src/pages/SettingsPage.jsx
index 85ae0ae..c19c0a4 100644
--- a/src/pages/SettingsPage.jsx
+++ b/src/pages/SettingsPage.jsx
@@ -268,8 +268,9 @@ export default function SettingsPage() {
setIsRefreshing(true);
setRefreshError(false);
try {
- const success = await refreshRateLimit();
- if (!success) {
+ const result = await refreshRateLimit();
+ // 'superseded' means a PAT change discarded this result — not a failure.
+ if (result === false) {
setRefreshError(true);
setTimeout(() => setRefreshError(false), 2000);
}
diff --git a/src/services/github.cacheKey.test.js b/src/services/github.cacheKey.test.js
index 6e0b942..0f69ae0 100644
--- a/src/services/github.cacheKey.test.js
+++ b/src/services/github.cacheKey.test.js
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
-import { normalizeCacheKey, fetchOrg, fetchRepos, fetchContributors } from './github'
+import { cacheGet, normalizeCacheKey, fetchOrg, fetchRepos, fetchContributors } from './github'
/** Minimal in-memory stand-in for the IndexedDB API surface used by github.js. */
function installFakeIndexedDB() {
@@ -30,6 +30,12 @@ function installFakeIndexedDB() {
queueMicrotask(() => req.onsuccess && req.onsuccess())
return req
},
+ delete(k) {
+ table.delete(k)
+ const req = {}
+ queueMicrotask(() => req.onsuccess && req.onsuccess())
+ return req
+ },
clear() {
table.clear()
const req = {}
@@ -53,6 +59,7 @@ function installFakeIndexedDB() {
return req
},
})
+ return tables
}
/** Counting fetch stub. Handler maps a URL to { status, body }. */
@@ -111,6 +118,14 @@ describe('normalizeCacheKey', () => {
)
})
+ it('preserves case in path segments beyond owner and repository names', () => {
+ expect(
+ normalizeCacheKey('https://api.github.com/repos/AOSSIE-Org/Repo-A/contents/ReadMe.md')
+ ).toBe(
+ 'https://api.github.com/repos/aossie-org/repo-a/contents/ReadMe.md'
+ )
+ })
+
it('sorts query parameters deterministically', () => {
expect(
normalizeCacheKey('https://api.github.com/orgs/aossie-org/repos?page=1&per_page=100')
@@ -133,6 +148,21 @@ describe('normalizeCacheKey', () => {
})
describe('case-insensitive caching', () => {
+ it('migrates an existing raw URL cache entry to its normalized key', async () => {
+ const tables = installFakeIndexedDB()
+ const oldKey = 'https://api.github.com/orgs/AOSSIE-Org'
+ const normalizedKey = normalizeCacheKey(oldKey)
+ const record = { k: oldKey, v: { login: 'AOSSIE-Org' }, ts: Date.now() }
+ tables.set('cache', new Map([[oldKey, record]]))
+
+ await expect(cacheGet(oldKey)).resolves.toEqual(record.v)
+ expect(tables.get('cache').get(normalizedKey)).toEqual({
+ ...record,
+ k: normalizedKey,
+ })
+ expect(tables.get('cache').has(oldKey)).toBe(false)
+ })
+
it('re-searching an org with different case costs zero extra fetches', async () => {
installFakeIndexedDB()
const calls = installCountingFetch({ login: 'AOSSIE-Org', public_repos: 1 })
@@ -167,22 +197,38 @@ describe('case-insensitive caching', () => {
})
describe('empty-repository responses', () => {
- for (const status of [204, 409]) {
- it(`caches contributors ${status} (empty repo) so repeats cost nothing`, async () => {
- installFakeIndexedDB()
- const calls = installFetchStub(() => ({ status, body: null }))
-
- const first = await fetchContributors('AOSSIE-Org', 'empty-repo', 'pat')
- expect(first).toEqual([])
- expect(calls).toHaveLength(1)
-
- // Previously this threw inside res.json() on every call, was never
- // cached, and burned one token per re-search.
- const second = await fetchContributors('AOSSIE-Org', 'empty-repo', 'pat')
- expect(second).toEqual([])
- expect(calls).toHaveLength(1)
- })
- }
+ it('does not treat an empty object response as an empty list', async () => {
+ installFakeIndexedDB()
+ const calls = installFetchStub(() => ({ status: 204, body: null }))
+
+ await expect(fetchOrg('AOSSIE-Org', 'pat')).rejects.toThrow('HTTP_204')
+ await expect(fetchOrg('AOSSIE-Org', 'pat')).rejects.toThrow('HTTP_204')
+ expect(calls).toHaveLength(2)
+ })
+
+ it('caches contributors 204 (empty repo) so repeats cost nothing', async () => {
+ installFakeIndexedDB()
+ const calls = installFetchStub(() => ({ status: 204, body: null }))
+
+ const first = await fetchContributors('AOSSIE-Org', 'empty-repo', 'pat')
+ expect(first).toEqual([])
+ expect(calls).toHaveLength(1)
+
+ // Previously this threw inside res.json() on every call, was never
+ // cached, and burned one token per re-search.
+ const second = await fetchContributors('AOSSIE-Org', 'empty-repo', 'pat')
+ expect(second).toEqual([])
+ expect(calls).toHaveLength(1)
+ })
+
+ it('rejects 409 responses instead of caching them as empty results', async () => {
+ installFakeIndexedDB()
+ const calls = installFetchStub(() => ({ status: 409, body: null }))
+
+ await expect(fetchContributors('AOSSIE-Org', 'repo-a', 'pat')).rejects.toThrow('HTTP_409')
+ await expect(fetchContributors('AOSSIE-Org', 'repo-a', 'pat')).rejects.toThrow('HTTP_409')
+ expect(calls).toHaveLength(2)
+ })
it('still throws RATE_LIMIT (403) and NOT_FOUND (404)', async () => {
installFakeIndexedDB()
diff --git a/src/services/github.js b/src/services/github.js
index 2cc733b..3f4d5a0 100644
--- a/src/services/github.js
+++ b/src/services/github.js
@@ -3,6 +3,13 @@ const DB_NAME = 'orgexplorer_cache'
const STORE = 'cache'
const TTL_MS = 3_600_000 // 1 hour
+// PAT generation: bumped every time the saved PAT changes (even A -> B -> A).
+// Requests capture it at start and drop their quota event if it changed,
+// so a late response from an old identity never overwrites current state.
+let patGeneration = 0
+export function bumpPatGeneration() { patGeneration += 1 }
+export function getPatGeneration() { return patGeneration }
+
/**
* Canonicalize an API URL into a deterministic cache key.
*
@@ -19,7 +26,15 @@ const TTL_MS = 3_600_000 // 1 hour
export function normalizeCacheKey(url) {
try {
const u = new URL(url)
- u.pathname = (u.pathname.replace(/\/+$/, '') || '/').toLowerCase()
+ const path = u.pathname.replace(/\/+$/, '') || '/'
+ const segments = path.split('/')
+ if (segments[1] === 'orgs' && segments[2]) {
+ segments[2] = segments[2].toLowerCase()
+ } else if (segments[1] === 'repos' && segments[2] && segments[3]) {
+ segments[2] = segments[2].toLowerCase()
+ segments[3] = segments[3].toLowerCase()
+ }
+ u.pathname = segments.join('/')
u.searchParams.sort()
return u.toString()
} catch {
@@ -39,12 +54,29 @@ function openDB() {
export async function cacheGet(key) {
try {
const db = await openDB()
- return new Promise(res => {
- const req = db.transaction(STORE, 'readonly').objectStore(STORE).get(normalizeCacheKey(key))
+ return new Promise((res, rej) => {
+ const normalizedKey = normalizeCacheKey(key)
+ const store = db.transaction(STORE, 'readonly').objectStore(STORE)
+ const req = store.get(normalizedKey)
req.onsuccess = () => {
const r = req.result
- if (!r || Date.now() - r.ts > TTL_MS) return res(null)
- res(r.v)
+ if (r) return res(Date.now() - r.ts > TTL_MS ? null : r.v)
+ if (key === normalizedKey) return res(null)
+
+ const legacyReq = store.get(key)
+ legacyReq.onsuccess = () => {
+ const legacy = legacyReq.result
+ if (!legacy || Date.now() - legacy.ts > TTL_MS) return res(null)
+ const migrationTx = db.transaction(STORE, 'readwrite')
+ const migrationStore = migrationTx.objectStore(STORE)
+ migrationStore
+ .put({ ...legacy, k: normalizedKey })
+ migrationStore.delete(key)
+ migrationTx.oncomplete = () => res(legacy.v)
+ migrationTx.onerror = () => rej(migrationTx.error || new Error('Failed to migrate cache entry'))
+ migrationTx.onabort = () => rej(migrationTx.error || new Error('Failed to migrate cache entry'))
+ }
+ legacyReq.onerror = () => res(null)
}
req.onerror = () => res(null)
})
@@ -76,7 +108,8 @@ export async function cacheClear() {
}
// Core fetchWithCache
-async function fetchWithCache(url, pat) {
+async function fetchWithCache(url, pat, emptyResult) {
+ const generationAtStart = patGeneration
// L2 check
const cached = await cacheGet(url)
if (cached) return cached
@@ -86,30 +119,39 @@ async function fetchWithCache(url, pat) {
const res = await fetch(url, { headers })
- window.dispatchEvent(
- new CustomEvent('rate-limit-update', {
- detail: {
- limit: Number(res.headers.get('x-ratelimit-limit')),
- remaining: Number(res.headers.get('x-ratelimit-remaining')),
- used: Number(res.headers.get('x-ratelimit-used')),
- reset: Number(res.headers.get('x-ratelimit-reset'))
- }
- })
- )
+ const live = readRateLimitHeaders(res.headers)
+ if (live) {
+ // Drop quota events from a superseded PAT: if the user saved a new
+ // token while this request was in flight, its counters belong to the
+ // old identity and must not overwrite the cleared/current state.
+ // Generation catches even A -> B -> A (same value, new save).
+ let superseded = generationAtStart !== patGeneration
+ if (!superseded) {
+ try {
+ const currentPat = typeof localStorage !== 'undefined' ? localStorage.getItem('oe_pat') || '' : null
+ if (currentPat !== null) superseded = (pat || '') !== currentPat
+ } catch { /* keep generation-check result on storage failure */ }
+ }
+ if (!superseded) {
+ window.dispatchEvent(
+ new CustomEvent('rate-limit-update', { detail: live })
+ )
+ }
+ }
if (res.status === 403) throw new Error('RATE_LIMIT')
if (res.status === 404) throw new Error('NOT_FOUND')
- if (res.status === 204 || res.status === 409) {
+ if (res.status === 204) {
+ if (emptyResult === undefined) throw new Error(`HTTP_${res.status}`)
// Empty repository (e.g. contributors on a repo with no commits):
// GitHub answers with no JSON body, so res.json() below would throw
// and the failure would never be cached — every re-search would spend
// another token on the same URL. Cache the empty result instead.
// All list-endpoint callers treat [] as "no data", and analytics
// already defaults missing entries to [].
- const empty = []
- cacheSet(url, empty) // write-back, non-blocking
- return empty
+ cacheSet(url, emptyResult) // write-back, non-blocking
+ return emptyResult
}
if (!res.ok) throw new Error(`HTTP_${res.status}`)
@@ -128,7 +170,7 @@ export async function fetchRepos(org, repoCount, pat) {
const maxPages = pat ? Math.ceil(repoCount / 100) : 5
for (let page = 1; page <= maxPages; page++) {
const url = `https://api.github.com/orgs/${org}/repos?per_page=100&page=${page}&sort=updated`
- const data = await fetchWithCache(url, pat)
+ const data = await fetchWithCache(url, pat, [])
all.push(...data)
if (data.length < 100) break
}
@@ -140,7 +182,7 @@ export async function fetchContributors(org, repo, pat) {
const maxPages = pat ? 10 : 1
for(let page = 1; page<=maxPages ; page++) {
const url = `https://api.github.com/repos/${org}/${repo}/contributors?per_page=100&page=${page}`
- const data = await fetchWithCache(url, pat)
+ const data = await fetchWithCache(url, pat, [])
all.push(...data)
if(data.length < 100) break
}
@@ -152,7 +194,7 @@ export async function fetchIssues(org, repo, pat) {
const maxPages = pat ? 10 : 1
for(let page = 1; page<=maxPages ; page++) {
const url = `https://api.github.com/repos/${org}/${repo}/issues?state=all&per_page=100&page=${page}`
- const data = await fetchWithCache(url, pat)
+ const data = await fetchWithCache(url, pat, [])
all.push(...data)
if(data.length < 100) break
}
@@ -164,19 +206,62 @@ export async function fetchPulls(org, repo, pat) {
const maxPages = pat ? 10 : 1
for(let page = 1; page<=maxPages ; page++) {
const url = `https://api.github.com/repos/${org}/${repo}/pulls?state=all&per_page=100&page=${page}`
- const data = await fetchWithCache(url, pat)
+ const data = await fetchWithCache(url, pat, [])
all.push(...data)
if(data.length < 100) break
}
return all
}
+/** Shared numeric validation, nonnegative checks and reset fallback. */
+export function normalizeRateLimit(limitRaw, remainingRaw, usedRaw, resetRaw) {
+ if (limitRaw == null || remainingRaw == null) return null
+ const limit = Number(limitRaw)
+ const remaining = Number(remainingRaw)
+ if (!Number.isFinite(limit) || !Number.isFinite(remaining)) return null
+ if (limit < 0 || remaining < 0) return null
+ const used = Number(usedRaw)
+ const reset = Number(resetRaw)
+ return {
+ limit,
+ remaining,
+ used: Number.isFinite(used) ? used : limit - remaining,
+ reset: Number.isFinite(reset) ? reset : 0,
+ }
+}
+
+/** Read live counters from response headers (authoritative per GitHub docs).
+ * Returns null when the headers are absent so callers can fall back. */
+function readRateLimitHeaders(h) {
+ if (!h || typeof h.get !== 'function') return null
+ const rawLimit = h.get('x-ratelimit-limit')
+ const rawRemaining = h.get('x-ratelimit-remaining')
+ if (rawLimit == null || rawRemaining == null) return null
+ const rawUsed = Number(h.get('x-ratelimit-used'))
+ const rawReset = h.get('x-ratelimit-reset')
+ // `x-ratelimit-used` is not in Access-Control-Expose-Headers, so browsers
+ // always read it as null -> 0. Derive it instead of showing a false 0.
+ const usedRaw = Number.isFinite(rawUsed) && rawUsed > 0 ? rawUsed : undefined
+ return normalizeRateLimit(rawLimit, rawRemaining, usedRaw, rawReset)
+}
+
+/** Validate a rate-limit object from the `/rate_limit` body. */
+export function asValidRateLimit(obj) {
+ if (!obj) return null
+ return normalizeRateLimit(obj.limit, obj.remaining, obj.used, obj.reset)
+}
+
export async function fetchRateLimit(pat) {
try {
const headers = { Accept: 'application/vnd.github.v3+json' }
if (pat) headers.Authorization = `token ${pat}`
const res = await fetch('https://api.github.com/rate_limit', { headers })
- const data = await res.json()
- return data.rate
+ const data = await res.json().catch(() => null)
+ // Headers are the authoritative source; the body (`rate` is closing
+ // down, and the body can disagree with the live counters) is fallback.
+ return readRateLimitHeaders(res.headers)
+ ?? asValidRateLimit(data?.resources?.core)
+ ?? asValidRateLimit(data?.rate)
+ ?? null
} catch { return null }
}