From f9b29d8d145e3bf8b0a1913cdf185cc89c4a52c1 Mon Sep 17 00:00:00 2001 From: timmy Date: Tue, 25 Aug 2026 22:30:14 +0000 Subject: [PATCH] feat: keep progressive My Work live (Closes #1411) --- frontend/progressive-my-work.js | 80 +++++++++++++++---- tests/test_progressive_my_work.py | 123 +++++++++++++++++++++++++++++- 2 files changed, 186 insertions(+), 17 deletions(-) diff --git a/frontend/progressive-my-work.js b/frontend/progressive-my-work.js index 6674456..e286227 100644 --- a/frontend/progressive-my-work.js +++ b/frontend/progressive-my-work.js @@ -1,8 +1,11 @@ -function createProgressiveMyWork({ document, fetchSnapshot }) { +function createProgressiveMyWork({ + document, fetchSnapshot, pollerOptions = {}, lifecycleTarget = globalThis, +}) { const list = document.querySelector('#my-work-list'); const status = document.querySelector('#my-work-status'); const filters = Array.from(document.querySelectorAll('[data-work-filter]')); const listeners = []; + const lifecycleListeners = []; let items = []; let active = 'all'; let stopped = false; @@ -10,6 +13,7 @@ function createProgressiveMyWork({ document, fetchSnapshot }) { let liveSnapshot = null; let liveSnapshotPromise = null; let confirmedLogin = ''; + let poller = null; const deferredQueues = { today:'Today', agenda:'Agenda', later:'Later', draft:'Drafts', }; @@ -60,6 +64,40 @@ function createProgressiveMyWork({ document, fetchSnapshot }) { listeners.push([button, listener]); }); + const applySnapshot = snapshot => { + if (stopped) return false; + const transferable = snapshot && typeof snapshot === 'object' && + Object.prototype.hasOwnProperty.call(snapshot, 'context'); + if (transferable) liveSnapshot = snapshot; + const context = snapshot?.context || snapshot || {}; + confirmedLogin = String(context.user?.login || '').trim(); + items = buildMyWork({ ...context, notifications:snapshot?.notifications || context.notifications || [] }); + updateCounts(); + render(); + const assigned = items.filter(item => item.is_assigned).length; + if (status) status.textContent = assigned + ' assigned work item' + (assigned === 1 ? '' : 's') + ' ready.'; + return true; + }; + + if (typeof createContextPoller === 'function') { + poller = createContextPoller({ + ...pollerOptions, + fetchContext: fetchSnapshot, + onSnapshot: applySnapshot, + onError: () => { + if (status && !stopped) status.textContent = 'Assigned work is reconnecting…'; + }, + isHidden: pollerOptions.isHidden || (() => Boolean(document.hidden)), + }); + const recover = () => { + if (!document.hidden) void poller.refresh(); + }; + ['online', 'visibilitychange'].forEach(eventName => { + lifecycleTarget.addEventListener?.(eventName, recover); + lifecycleListeners.push([eventName, recover]); + }); + } + return { login() { return confirmedLogin; }, handoff() { @@ -76,6 +114,14 @@ function createProgressiveMyWork({ document, fetchSnapshot }) { }, async start() { if (status) status.textContent = 'Loading assigned work…'; + if (poller) { + const request = poller.start(); + liveSnapshotPromise = request.then(snapshot => ( + snapshot && typeof snapshot === 'object' && + Object.prototype.hasOwnProperty.call(snapshot, 'context') ? snapshot : null + )); + return Boolean(await request); + } const request = Promise.resolve().then(() => fetchSnapshot()); liveSnapshotPromise = request.then(snapshot => ( snapshot && typeof snapshot === 'object' && @@ -84,18 +130,9 @@ function createProgressiveMyWork({ document, fetchSnapshot }) { try { const snapshot = await request; if (stopped) return false; - const transferable = snapshot && typeof snapshot === 'object' && - Object.prototype.hasOwnProperty.call(snapshot, 'context'); - if (transferable) liveSnapshot = snapshot; - else liveSnapshotPromise = null; - const context = snapshot?.context || snapshot || {}; - confirmedLogin = String(context.user?.login || '').trim(); - items = buildMyWork({ ...context, notifications:snapshot?.notifications || context.notifications || [] }); - updateCounts(); - render(); - const assigned = items.filter(item => item.is_assigned).length; - if (status) status.textContent = assigned + ' assigned work item' + (assigned === 1 ? '' : 's') + ' ready.'; - return true; + const applied = applySnapshot(snapshot); + if (!liveSnapshot) liveSnapshotPromise = null; + return applied; } catch (_error) { if (status && !stopped) status.textContent = 'Assigned work is reconnecting…'; return false; @@ -103,7 +140,10 @@ function createProgressiveMyWork({ document, fetchSnapshot }) { }, stop() { stopped = true; + poller?.stop(); listeners.forEach(([button, listener]) => button.removeEventListener?.('click', listener)); + lifecycleListeners.forEach(([eventName, listener]) => + lifecycleTarget.removeEventListener?.(eventName, listener)); }, }; } @@ -111,9 +151,17 @@ function createProgressiveMyWork({ document, fetchSnapshot }) { if (typeof window !== 'undefined' && typeof document !== 'undefined') { window.stackchainProgressiveMyWork = createProgressiveMyWork({ document, - fetchSnapshot: async () => { - const response = await fetch('api/v1/live', { headers:{Accept:'application/json'} }); - if (!response.ok) throw new Error('HTTP ' + response.status); + lifecycleTarget: window, + fetchSnapshot: async (revisions = {}, { signal } = {}) => { + const query = createContextPoller.buildRevisionQuery(revisions); + const response = await fetch('api/v1/live' + (query ? '?' + query : ''), { + headers:{Accept:'application/json'}, signal, + }); + if (!response.ok) { + const error = new Error('HTTP ' + response.status); + error.retryAfterMs = createContextPoller.retryAfterMs(response.headers.get('Retry-After')); + throw error; + } return response.json(); }, }); diff --git a/tests/test_progressive_my_work.py b/tests/test_progressive_my_work.py index 369b48c..e7f9245 100644 --- a/tests/test_progressive_my_work.py +++ b/tests/test_progressive_my_work.py @@ -243,4 +243,125 @@ const flow=context.module.exports({{document,fetchSnapshot:async()=>({{ """ result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True) - assert json.loads(result.stdout) == {"before": "", "after": "timmy", "stopped": "timmy"} \ No newline at end of file + assert json.loads(result.stdout) == {"before": "", "after": "timmy", "stopped": "timmy"} + + +def test_progressive_my_work_retries_a_failed_snapshot_without_reloading(): + harness = f""" +const fs=require('fs'); const vm=require('vm'); +const list={{innerHTML:''}}; const status={{textContent:''}}; +const document={{ + querySelector:selector=>selector==='#my-work-list'?list:selector==='#my-work-status'?status:null, + querySelectorAll:()=>[], +}}; +let timer=null; let attempts=0; +const context={{module:{{exports:{{}}}},exports:{{}},console,URL,URLSearchParams,AbortController,document}}; +vm.createContext(context); +vm.runInContext(fs.readFileSync({json.dumps(str(MY_WORK))},'utf8'),context); +context.buildMyWork=context.module.exports; context.module={{exports:{{}}}}; +vm.runInContext(fs.readFileSync({json.dumps(str(Path(__file__).parents[1] / 'frontend' / 'context-poller.js'))},'utf8'),context); +context.createContextPoller=context.module.exports; context.module={{exports:{{}}}}; +vm.runInContext(fs.readFileSync({json.dumps(str(MODULE))},'utf8'),context); +const flow=context.module.exports({{ + document, + fetchSnapshot:async()=>{{ + attempts += 1; + if(attempts===1) throw new Error('temporary outage'); + return {{context:{{user:{{login:'timmy'}},issues:[{{ + number:9,title:'Recovered assignment',repository:'stackchain/dashboard', + assignees:['timmy'],url:'https://forge.example/issues/9', + }}],pull_requests:[]}},events:[],notifications:[]}}; + }}, + pollerOptions:{{ + setTimer:callback=>{{timer=callback; return 1;}}, clearTimer:()=>{{timer=null;}}, + setDeadlineTimer:()=>2, clearDeadlineTimer:()=>{{}}, intervalMs:5, + }}, +}}); +(async()=>{{ + const first=await flow.start(); + const reconnecting=status.textContent; + if(!timer) throw new Error('retry was not scheduled'); + timer(); await Promise.resolve(); await Promise.resolve(); await new Promise(resolve=>setImmediate(resolve)); + console.log(JSON.stringify({{first,reconnecting,attempts,status:status.textContent,html:list.innerHTML}})); +}})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True) + + assert json.loads(result.stdout) == { + "first": False, + "reconnecting": "Assigned work is reconnecting…", + "attempts": 2, + "status": "1 assigned work item ready.", + "html": '
Recovered assignmentstackchain/dashboard#9 · Assigned to you
', + } + + +def test_progressive_my_work_refreshes_with_revisions_and_coalesces_lifecycle_recovery(): + harness = f""" +const fs=require('fs'); const vm=require('vm'); +const list={{innerHTML:''}}; const status={{textContent:''}}; +const document={{ + hidden:false, + querySelector:selector=>selector==='#my-work-list'?list:selector==='#my-work-status'?status:null, + querySelectorAll:()=>[], +}}; +const handlers={{}}; const removed=[]; +const lifecycleTarget={{ + addEventListener:(name,callback)=>{{handlers[name]=callback;}}, + removeEventListener:(name,callback)=>{{if(handlers[name]===callback){{removed.push(name);delete handlers[name];}}}}, +}}; +let timer=null; let calls=[]; let resolveRefresh; +const initial={{ + context:{{user:{{login:'timmy'}},issues:[],pull_requests:[]}},events:[],notifications:[], + revisions:{{context:'0123456789abcdef.1',events:'fedcba9876543210.2',notifications:'0011223344556677.3'}}, + freshness:{{fresh_for_seconds:8,sections:{{context:{{degraded:false,age_seconds:2}},events:{{degraded:false,age_seconds:2}},notifications:{{degraded:false,age_seconds:2}}}}}}, +}}; +const updated={{...initial,context:{{user:{{login:'timmy'}},issues:[{{number:10,title:'New assignment',repository:'stackchain/dashboard',assignees:['timmy']}}],pull_requests:[]}}}}; +const context={{module:{{exports:{{}}}},exports:{{}},console,URL,URLSearchParams,AbortController,document}}; +vm.createContext(context); +vm.runInContext(fs.readFileSync({json.dumps(str(MY_WORK))},'utf8'),context); +context.buildMyWork=context.module.exports; context.module={{exports:{{}}}}; +vm.runInContext(fs.readFileSync({json.dumps(str(Path(__file__).parents[1] / 'frontend' / 'context-poller.js'))},'utf8'),context); +context.createContextPoller=context.module.exports; context.module={{exports:{{}}}}; +vm.runInContext(fs.readFileSync({json.dumps(str(MODULE))},'utf8'),context); +const flow=context.module.exports({{ + document,lifecycleTarget, + fetchSnapshot:(revisions,options)=>{{ + calls.push({{revisions,hasSignal:Boolean(options.signal)}}); + if(calls.length===1)return Promise.resolve(initial); + return new Promise(resolve=>{{resolveRefresh=()=>resolve(updated);}}); + }}, + pollerOptions:{{ + setTimer:callback=>{{timer=callback;return 1;}},clearTimer:()=>{{timer=null;}}, + setDeadlineTimer:()=>2,clearDeadlineTimer:()=>{{}},intervalMs:5, + }}, +}}); +(async()=>{{ + await flow.start(); const freshnessTimer=timer; freshnessTimer(); + handlers.online(); handlers.visibilitychange(); + await Promise.resolve(); + const callsWhilePending=calls.length; + resolveRefresh(); await Promise.resolve(); await new Promise(resolve=>setImmediate(resolve)); + const handoff=flow.handoff(); flow.stop(); + console.log(JSON.stringify({{ + callsWhilePending,calls,latestTitle:handoff.liveSnapshot.context.issues[0].title, + removed:removed.sort(),status:status.textContent, + }})); +}})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True) + state = json.loads(result.stdout) + + assert state["callsWhilePending"] == 2 + assert state["calls"][0] == {"revisions": {}, "hasSignal": True} + assert state["calls"][1] == { + "revisions": { + "context": "0123456789abcdef.1", + "events": "fedcba9876543210.2", + "notifications": "0011223344556677.3", + }, + "hasSignal": True, + } + assert state["latestTitle"] == "New assignment" + assert state["removed"] == ["online", "visibilitychange"] + assert state["status"] == "1 assigned work item ready." -- 2.43.0