diff --git a/frontend/inbox-loading.test.mjs b/frontend/inbox-loading.test.mjs new file mode 100644 index 0000000..55e4a0b --- /dev/null +++ b/frontend/inbox-loading.test.mjs @@ -0,0 +1,255 @@ +/** + * Inbox loading-state test — what the queue SHOWS while its data is in flight. + * + * node inbox-loading.test.mjs + * + * The complaint this pins down: opening the Recruitment Inbox showed skeleton + * placeholders every single time, even on a revisit, and on the combined "All" + * channel it showed them stacked ABOVE rows that had already arrived. The queue + * is a local table that only grows when the Sync worker writes to it, so a + * revisit has no business blanking itself. + * + * Every fetch here is gated by hand, so the assertions land on the exact frames + * a fast local API would flash past: both sources pending, one source home, and + * a second visit served from cache. + */ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' + +import esbuild from 'esbuild' +import { JSDOM } from 'jsdom' + +// ---------------------------------------------------------------- environment +const dom = new JSDOM('
', { + url: 'http://localhost:5173/', + pretendToBeVisual: true, +}) + +globalThis.window = dom.window +globalThis.document = dom.window.document +Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true }) +globalThis.HTMLElement = dom.window.HTMLElement +globalThis.Element = dom.window.Element +globalThis.Node = dom.window.Node +globalThis.getComputedStyle = dom.window.getComputedStyle +globalThis.localStorage = dom.window.localStorage +globalThis.DOMParser = dom.window.DOMParser +globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 0) +globalThis.cancelAnimationFrame = clearTimeout +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +class RO { observe() {} unobserve() {} disconnect() {} } +class MO { observe() {} disconnect() {} takeRecords() { return [] } } +globalThis.ResizeObserver = RO +globalThis.MutationObserver = MO +dom.window.ResizeObserver = RO +dom.window.MutationObserver = MO +dom.window.matchMedia = () => ({ + matches: false, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, +}) +dom.window.HTMLCanvasElement.prototype.getContext = () => + new Proxy({}, { get: (_, k) => (k === 'canvas' ? {} : () => {}) }) + +const MODULES = ['dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'assessments', + 'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users', 'tasks', 'talent', + 'requisitions'] +const ACTIONS = ['view', 'create', 'edit', 'delete', 'approve', 'export', 'manage', 'configure'] +const permissions = MODULES.flatMap((m) => ACTIONS.map((a) => `${m}.${a}`)) + +dom.window.localStorage.setItem('tf-auth', JSON.stringify({ + access_token: 'test', refresh_token: 'test', expires_in: 1800, + expires_at: Date.now() + 1800_000, + data: { id: 1, name: 'Test User', email: 't@example.com', role_name: 'system_administrator', permissions }, +})) + +// ---------------------------------------------------------------- fixtures +const EMAIL_ROWS = [ + { + id: '11111111-1111-1111-1111-111111111111', + name: 'Ada Lovelace', email: 'ada@example.com', + position: 'Backend Engineer', source: 'careers-rozee@example.com', + received: '2026-09-02T10:00:00Z', unread: true, processing: 'Unread', + }, + { + id: '22222222-2222-2222-2222-222222222222', + name: 'Grace Hopper', email: 'grace@example.com', + position: 'Platform Engineer', source: 'careers-rozee@example.com', + received: '2026-09-01T10:00:00Z', unread: false, processing: 'Read', + }, +] + +const FORM_ROWS = [ + { + id: '33333333-3333-3333-3333-333333333333', + name: 'Katherine Johnson', candidate_email: 'kj@example.com', + position_applied_for: 'Data Analyst', source_of_application: 'LinkedIn', + entry_date: '2026-09-03T00:00:00Z', entry_time: '09:15', processing_state: 'unread', + }, +] + +/** + * One gate per source. `open()` releases every request parked on it, and every + * request that arrives afterwards resolves straight away — which is what makes + * "email home, sheet still travelling" an assertable frame rather than a race. + */ +function gate(payload) { + const waiting = [] + let open = false + return { + calls: 0, + payload, + take() { + this.calls += 1 + if (open) return Promise.resolve(this.payload) + return new Promise((resolve) => waiting.push(() => resolve(this.payload))) + }, + release() { + open = true + waiting.splice(0).forEach((fn) => fn()) + }, + close() { open = false }, + } +} + +const emailGate = gate({ data: EMAIL_ROWS, total: EMAIL_ROWS.length, status_code: 200 }) +const formGate = gate({ data: FORM_ROWS, total: FORM_ROWS.length, status_code: 200 }) + +const COUNTS = { all: 3, unread: 1, processed: 0, rejected: 0, duplicates: 0 } + +globalThis.fetch = async (input) => { + const url = String(input?.url ?? input) + let body + if (url.includes('/inbox/all-applications/count')) body = { total: EMAIL_ROWS.length, status_code: 200 } + else if (url.includes('/inbox/all-applications')) body = await emailGate.take() + else if (url.includes('/inbox/counts')) body = { data: COUNTS, status_code: 200 } + else if (url.includes('/sheet/form-data/count')) body = { total: FORM_ROWS.length, status_code: 200 } + else if (url.includes('/sheet/form-data/counts')) body = { data: { all: 1 }, status_code: 200 } + else if (url.includes('/sheet/form-data/fetch')) body = await formGate.take() + else body = { data: [], status_code: 200 } + return { + ok: true, status: 200, statusText: 'OK', + text: async () => JSON.stringify(body), + } +} + +// ---------------------------------------------------------------- bundle +const outDir = mkdtempSync(join(tmpdir(), 'tf-inbox-')) +const outFile = join(outDir, 'entry.mjs') + +await esbuild.build({ + entryPoints: ['src/__smoke__/entry.jsx'], + outfile: outFile, + bundle: true, + format: 'esm', + platform: 'node', + target: 'node20', + jsx: 'automatic', + loader: { '.js': 'jsx', '.jsx': 'jsx' }, + logLevel: 'error', + define: { + 'process.env.NODE_ENV': '"development"', + 'import.meta.env': JSON.stringify({ DEV: true, PROD: false, MODE: 'development', VITE_API_BASE: '' }), + }, +}) + +// ---------------------------------------------------------------- run +let failed = 0 +function check(name, condition, detail) { + if (condition) { + console.log(`ok ${name}`) + if (detail) console.log(` ${detail}`) + } else { + console.log(`FAIL ${name}`) + if (detail) console.log(` ${detail}`) + failed++ + } +} + +const hasSkeleton = (html) => html.includes('skeleton-row') +const hasRefreshBar = (html) => html.includes('inbox-refresh-bar') +const rowCount = (html) => (html.match(/class="inbox-item/g) || []).length + +try { + const mod = await import(pathToFileURL(outFile).href) + mod.boot() + + const container = dom.window.document.createElement('div') + dom.window.document.body.appendChild(container) + + // ---- frame 1: cold open, neither source home ---------------------------- + const view = await mod.mountRoute('/inbox', container) + check( + 'cold open with nothing cached shows placeholders', + hasSkeleton(view.html()) && rowCount(view.html()) === 0, + `skeleton=${hasSkeleton(view.html())} rows=${rowCount(view.html())}`, + ) + + // ---- frame 2: email home, sheet still travelling ------------------------ + // THE REGRESSION: this frame used to render six placeholders on top of two + // real rows, because the All channel ORs the two pending flags together. + emailGate.release() + await view.settle(60) + const partial = view.html() + check( + 'THE REGRESSION: rows that arrived are never buried under placeholders', + !hasSkeleton(partial) && rowCount(partial) === EMAIL_ROWS.length, + `skeleton=${hasSkeleton(partial)} rows=${rowCount(partial)}`, + ) + check( + 'the half still in flight is named, not mimed', + view.text().includes('Still loading Sheet Form applications'), + ) + check( + 'a refetch over live rows is a hairline bar', + hasRefreshBar(partial), + ) + + // ---- frame 3: both home ------------------------------------------------- + formGate.release() + await view.settle(60) + const settled = view.html() + check( + 'both sources merge into one list', + rowCount(settled) === EMAIL_ROWS.length + FORM_ROWS.length, + `rows=${rowCount(settled)}`, + ) + check( + 'no placeholders and no progress bar once the queue is settled', + !hasSkeleton(settled) && !hasRefreshBar(settled), + ) + check( + 'the list says how old it is', + view.text().includes('Updated just now'), + ) + + const firstVisitEmailCalls = emailGate.calls + await view.unmount() + + // ---- frame 4: leave, come back ----------------------------------------- + // The headline fix. Same query keys, inside staleTime: React Query serves + // the cache, so the first painted frame already has rows and no request goes + // out. This is the assertion that fails if LIST_CACHE is ever removed. + const second = dom.window.document.createElement('div') + dom.window.document.body.appendChild(second) + const revisit = await mod.mountRoute('/inbox', second) + const firstFrame = revisit.html() + check( + 'THE FIX: re-opening the Inbox paints rows immediately, no placeholders', + !hasSkeleton(firstFrame) && rowCount(firstFrame) === EMAIL_ROWS.length + FORM_ROWS.length, + `skeleton=${hasSkeleton(firstFrame)} rows=${rowCount(firstFrame)}`, + ) + await revisit.settle(60) + check( + 're-opening inside staleTime issues no new list request', + emailGate.calls === firstVisitEmailCalls, + `calls before=${firstVisitEmailCalls} after=${emailGate.calls}`, + ) + await revisit.unmount() +} finally { + rmSync(outDir, { recursive: true, force: true }) +} + +console.log(failed ? `\n${failed} inbox loading check(s) FAILED` : '\nAll inbox loading checks passed') +process.exit(failed ? 1 : 0) diff --git a/frontend/package.json b/frontend/package.json index 1ab5aea..c3763f3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,8 +11,9 @@ "smoke": "node smoke.test.mjs", "test:token": "node token.test.mjs", "test:theme": "node theme.test.mjs", + "test:inbox": "node inbox-loading.test.mjs", "test:mobile": "node mobile.test.mjs", - "verify": "vite build && node smoke.test.mjs && node token.test.mjs && node theme.test.mjs" + "verify": "vite build && node smoke.test.mjs && node token.test.mjs && node theme.test.mjs && node inbox-loading.test.mjs" }, "dependencies": { "@tanstack/react-query": "^5.101.4", diff --git a/frontend/src/__smoke__/entry.jsx b/frontend/src/__smoke__/entry.jsx index c06942c..aa7018f 100644 --- a/frontend/src/__smoke__/entry.jsx +++ b/frontend/src/__smoke__/entry.jsx @@ -78,8 +78,32 @@ export function boot() { initializeCache(queryClient) } -/** Mount one route, wait for effects to settle, return its rendered text. */ -export async function renderRoute(path, container) { +/** + * Like renderRoute, but hands the mount BACK instead of tearing it down. + * + * renderRoute answers "did this route render at all". The Inbox loading test + * asks a different question — what is on screen between one fetch resolving + * and the next — so it needs to step time itself and read the DOM at each + * step. `settle` must come from here, not the test file, because act() has to + * be the bundle's React, not a second copy. + */ +export async function mountRoute(path, container) { + const tree = routeTree(path) + const root = createRoot(container) + await act(async () => { root.render(tree) }) + const settle = async (ms = 20) => { + await act(async () => { await new Promise((r) => setTimeout(r, ms)) }) + } + await settle() + return { + settle, + html: () => container.innerHTML, + text: () => container.textContent || '', + unmount: async () => { await act(async () => { root.unmount() }) }, + } +} + +function routeTree(path) { const h = React.createElement const isAuth = path.startsWith('/auth/') const def = TABLE.find((r) => `/${r.path}` === path) @@ -93,7 +117,7 @@ export async function renderRoute(path, container) { h(Route, { path, element: h(Screen) }), ) - const tree = h( + return h( QueryClientProvider, { client: queryClient }, h(ThemeProvider, null, h(ToastProvider, null, @@ -105,7 +129,11 @@ export async function renderRoute(path, container) { ), ), ) +} +/** Mount one route, wait for effects to settle, return its rendered text. */ +export async function renderRoute(path, container) { + const tree = routeTree(path) const root = createRoot(container) try { await act(async () => { root.render(tree) }) diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index 988596f..95905f2 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -10,7 +10,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import Modal from '../ui/Modal' import EmailBody, { looksLikeHtml } from '../ui/EmailBody' @@ -42,6 +42,46 @@ const FORM_TABS = ['All Applications', 'Processed', 'Rejected', 'Duplicates'] /** Inbox GET `top` / sheet GET `limit` both cap at 500. */ const PAGE_SIZE_MAX = 500 +/** + * Cache policy for the queue. + * + * This list is a local table, not a live feed. Applications only appear when + * the Sync worker writes them, and that path already invalidates + * qk.mailbox.all() the moment a run completes — so refetching on every visit + * bought nothing and cost a full-width skeleton each time. The All channel + * makes that worse: it fetches BOTH sources unpaged, so the "loading" state a + * recruiter saw on re-entry was thousands of rows being re-downloaded to + * render the same ten. + * + * staleTime therefore covers a normal working stretch, and keepPreviousData + * means a tab switch, a page turn or a keystroke re-renders the rows already + * on screen instead of blanking them. Nothing here can hide new mail: Sync + * invalidates, and every write mutation on this screen already does too. + */ +const INBOX_STALE_MS = 10 * 60_000 +const INBOX_GC_MS = 60 * 60_000 +const LIST_CACHE = { + staleTime: INBOX_STALE_MS, + gcTime: INBOX_GC_MS, + placeholderData: keepPreviousData, +} +const COUNT_CACHE = { staleTime: INBOX_STALE_MS, gcTime: INBOX_GC_MS } + +/** Typing must not put a query key (and a skeleton) on screen per keystroke. */ +const SEARCH_DEBOUNCE_MS = 300 + +/** "Updated 4 min ago" under the search box — the honest label for a cached list. */ +function agoLabel(ts) { + if (!ts) return null + const secs = Math.max(0, Math.round((Date.now() - ts) / 1000)) + if (secs < 45) return 'just now' + const mins = Math.round(secs / 60) + if (mins < 60) return `${mins} min ago` + const hours = Math.round(mins / 60) + if (hours < 24) return `${hours} hr ago` + return `${Math.round(hours / 24)} d ago` +} + const SYNC_RUN_KEY = 'mailbox_sync_run_id' function readStoredSyncRunId() { @@ -379,7 +419,11 @@ async function fetchApplications(params) { filePath: row.file_path || '', linkedinSlug: row.linkedin_slug || '', linkedinUrl: row.linkedin_url || '', - resumeText: row.resume_text || '', + // resume_text is deliberately DROPPED here. serialize_application ships + // the whole extracted CV on every row, and the queue renders none of + // it — but the cache would then hold a thousand full resumes for the + // hour that LIST_CACHE keeps a page alive. The detail query fetches it + // for the one row actually open, which is the only place it is read. atsScore: row.ats_score, phone: row.phone, experience: row.experience, @@ -722,6 +766,47 @@ function BulkReadBar({ rows, selection, onSetRead, onSetAllRead, busy, canEdit, ) } +/** + * "Updated 4 min ago" + Refresh, under the search box. + * + * A cached list that never says how old it is reads as a broken list. This is + * the honest label, and the button next to it re-reads the DB — deliberately + * NOT the same act as Sync, which pulls new mail from Outlook. The tooltips + * say which is which because the two were previously indistinguishable. + * + * Owns its own interval so the minute count stays current without re-rendering + * the queue: under "Show all" that parent render is a thousand rows. + */ +function QueueFreshness({ at, refreshing, onRefresh }) { + const [, tick] = useState(0) + useEffect(() => { + if (!at) return undefined + const t = setInterval(() => tick((n) => n + 1), 30_000) + return () => clearInterval(t) + }, [at]) + + return ( +