From db08c7e75f47c21447838ac32540a7986c5659ed Mon Sep 17 00:00:00 2001 From: timmy Date: Thu, 13 Aug 2026 17:31:20 +0000 Subject: [PATCH] feat: prefetch the next mobile update (Closes #757) --- README.md | 6 ++- frontend/dashboard.js | 5 +++ frontend/my-work.js | 37 ++++++++++++--- frontend/update-triage-session.js | 7 +++ tests/test_frontend_bundle.py | 4 +- tests/test_my_work.py | 70 +++++++++++++++++++++++++++++ tests/test_update_triage_session.py | 21 +++++++++ 7 files changed, 141 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 409c3a5..581e635 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,11 @@ an open, unassigned issue update also offers **Take ownership & start**, which c before assignment, preserves the unread update, adds and syncs the owned issue to Today, checkpoints the session, and opens the issue. The adjacent **Take ownership** action remains available for claim-only triage, and a local start failure opens the now-owned issue with truthful recovery guidance. -**Mark read & next** remains the explicit acknowledgement path. Delivery or local-admission +**Mark read & next** remains the explicit acknowledgement path. During an online Updates pass, +Stackchain preloads at most the next surviving conversation from the fixed snapshot while the +current one is being read. Advancing consumes that account-bound result without another detail +request; failures fall back to the normal foreground retry path, and offline triage never speculates. +Delivery or local-admission failure preserves both the reply draft and checkpoint. Finishing or choosing **End session** clears only the checkpoint and leaves the Today plan unchanged. Another or unconfirmed account cannot see or resume it. Server revisions prevent delayed responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots, diff --git a/frontend/dashboard.js b/frontend/dashboard.js index c729512..1c2548f 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -912,6 +912,7 @@ }); const notificationReader = createNotificationReader({ load: fetchNotificationDetail, + getScope: () => confirmedOwnerLogin, loadConversation: fetchNotificationConversation, markRead: markNotificationRead, acknowledge: acknowledgeNotification, @@ -978,6 +979,9 @@ }, onStatus: message => { qs('#update-sheet-status').textContent = message; + if (message === 'Update ready.' && updateTriage.active() && !offlineWorkMode) { + notificationReader.prefetch(updateTriage.next()); + } qs('#retry-update-load').hidden = !message.startsWith('Could not load update.'); if (message === 'Inbox cleared.') { qs('#my-work-action-status').textContent = message; @@ -1001,6 +1005,7 @@ progress.textContent = 'Update ' + state.index + ' of ' + state.total; }, onFinish: () => { + notificationReader.prefetch(); qs('#update-triage-progress').hidden = true; showMobileQueueCompletion('Updates'); }, diff --git a/frontend/my-work.js b/frontend/my-work.js index d33001b..acaf87f 100644 --- a/frontend/my-work.js +++ b/frontend/my-work.js @@ -380,12 +380,27 @@ function createNotificationReader({ loadConversation = null, onConversation = () => {}, createPager = typeof createConversationPager === 'function' ? createConversationPager : null, + getScope = () => '', }) { let selected = null; let loadVersion = 0; let marking = false; let conversationPager = null; let offlineHydrated = false; + let prefetched = null; + let prefetchKey = ''; + + function prefetch(item) { + if (!item) { prefetched = null; prefetchKey = ''; return false; } + const notificationId = item?.notification_id; + if (!Number.isInteger(notificationId) || offlineHydrated) return false; + const key = getScope() + notificationId; + if (prefetchKey === key) return prefetched; + if (prefetched) return false; + prefetchKey = key; + prefetched = load(notificationId); + return prefetched; + } async function advanceAfterRead(items, current, queueing = false) { const updated = acknowledgeNotification(items, current.notification_id); @@ -407,10 +422,19 @@ function createNotificationReader({ selected = item; offlineHydrated = Boolean(savedDetail && !Array.isArray(savedDetail)); const version = ++loadVersion; + const preload = !offlineHydrated && prefetchKey === getScope() + item.notification_id ? prefetched : null; + prefetched = null; + prefetchKey = ''; onOpen(item); - onStatus('Loading update…'); + if (!preload) onStatus('Loading update…'); try { - const detail = offlineHydrated ? savedDetail : await load(item.notification_id); + let detail; + if (offlineHydrated) detail = savedDetail; + else if (preload) detail = await preload.catch(() => { + onStatus('Loading update…'); + return load(item.notification_id); + }); + else detail = await load(item.notification_id); if (selected !== item || version !== loadVersion) return false; onDetail(detail); if (createPager && loadConversation && detail.conversation) { @@ -433,6 +457,7 @@ function createNotificationReader({ return { open, + prefetch, commentPager() { return conversationPager; }, @@ -516,7 +541,7 @@ function createNotificationReplier({ if ((storage.getItem(keyFor(item)) || '') !== body) storage.removeItem(operationKeyFor(item)); storage.setItem(keyFor(item), body); } - catch (_error) { /* Keep the editable textarea as the fallback. */ } + catch (_error) {} }, async submit(item, body, attachment = null) { if (pending) return false; @@ -542,15 +567,15 @@ function createNotificationReplier({ return { queued:true }; } try { storage.removeItem(keyFor(item)); storage.removeItem(operationKeyFor(item)); } - catch (_error) { /* Confirmed delivery is authoritative. */ } + catch (_error) {} onStatus('Reply posted. You can mark this update read when ready.'); return delivery.confirmed[0]; } const result = await post(item.notification_id, body, operationId); try { storage.removeItem(keyFor(item)); } - catch (_error) { /* The posted reply is still authoritative. */ } + catch (_error) {} try { storage.removeItem(operationKeyFor(item)); } - catch (_error) { /* A confirmed result no longer needs replay identity. */ } + catch (_error) {} onStatus('Reply posted. You can mark this update read when ready.'); return result; } catch (error) { diff --git a/frontend/update-triage-session.js b/frontend/update-triage-session.js index 471d1da..3824cf2 100644 --- a/frontend/update-triage-session.js +++ b/frontend/update-triage-session.js @@ -35,6 +35,12 @@ .map(id => byIdentity.get(id)); } + function nextAvailable() { + if (!running || !state) return null; + const candidates = available().filter(item => identity(item) !== state.current); + return candidates.find(item => state.identities.indexOf(identity(item)) > state.identities.indexOf(state.current)) || candidates[0] || null; + } + function finish() { running = false; state = null; @@ -94,6 +100,7 @@ completeAndNext: advance, acceptCompleted: () => advance(false), keepUnreadAndNext: advance, + next: nextAvailable, items: () => state ? available().slice() : [], end: finish, }; diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py index ebf23b3..ab4e1cd 100644 --- a/tests/test_frontend_bundle.py +++ b/tests/test_frontend_bundle.py @@ -68,8 +68,8 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path): assert b"function attachSecurityCenter" in security_center.runtime_bytes assert b"gitea_time_logged" not in first.runtime_bytes assert b"gitea_time_logged" in security_center.runtime_bytes - # The recap adds only startup wiring; its UI remains in the lazy Today bundle. - assert len(first.runtime_gzip_bytes) <= 96 * 1024 + # One-ahead Updates prefetch stays in the core reader so transitions can reuse its in-flight request. + assert len(first.runtime_gzip_bytes) <= 97 * 1024 assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 3fcaaff..90bb9ce 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -4871,6 +4871,76 @@ reader.open(original[0], original).then(() => assert output["result"]["next"]["notification_id"] == 43 +def test_notification_reader_consumes_one_bounded_prefetch_without_duplicate_load(): + script = f""" +const buildMyWork = require({json.dumps(str(MY_WORK))}); +const items = [1,2,3].map(notification_id => ({{kind:'update', notification_id, has_update:true}})); +const loaded = [], details = [], statuses = []; +const releases = {{}}; +const reader = buildMyWork.createNotificationReader({{ + load: id => {{ + loaded.push(id); + if (id === 1) return Promise.resolve({{id}}); + return new Promise(resolve => {{ releases[id] = resolve; }}); + }}, + markRead: async () => {{}}, + onOpen: () => {{}}, onDetail: detail => details.push(detail.id), onItems: () => {{}}, + onStatus: status => statuses.push(status), onClose: () => {{}}, +}}); +(async () => {{ + await reader.open(items[0]); + const first = reader.prefetch(items[1]); + const duplicate = reader.prefetch(items[1]); + const rejected = reader.prefetch(items[2]); + const advancing = reader.open(items[1]); + await Promise.resolve(); + releases[2]({{id:2}}); + await advancing; + process.stdout.write(JSON.stringify({{ + loaded, details, same:first === duplicate, rejected, statuses, + }})); +}})(); +""" + output = json.loads(subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ).stdout) + + assert output == { + "loaded": [1, 2], + "details": [1, 2], + "same": True, + "rejected": False, + "statuses": ["Loading update…", "Update ready.", "Update ready."], + } + + +def test_notification_reader_never_consumes_prefetch_from_another_account_scope(): + script = f""" +const buildMyWork = require({json.dumps(str(MY_WORK))}); +let scope = 'timmy'; +const loaded = []; +const reader = buildMyWork.createNotificationReader({{ + load: async id => {{ loaded.push([scope, id]); return {{owner:scope, id}}; }}, + getScope: () => scope, + markRead: async () => {{}}, onOpen: () => {{}}, onItems: () => {{}}, onStatus: () => {{}}, + onDetail: detail => {{ if (detail.owner !== scope) throw new Error('cross-account detail'); }}, + onClose: () => {{}}, +}}); +(async () => {{ + await reader.open({{notification_id:1}}); + await reader.prefetch({{notification_id:2}}); + scope = 'alexander'; + const opened = await reader.open({{notification_id:2}}); + process.stdout.write(JSON.stringify({{loaded, opened}})); +}})(); +""" + output = json.loads(subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ).stdout) + + assert output == {"loaded": [["timmy", 1], ["timmy", 2], ["alexander", 2]], "opened": True} + + def test_notification_reader_acknowledges_once_and_opens_next_update(): script = f""" const buildMyWork = require({json.dumps(str(MY_WORK))}); diff --git a/tests/test_update_triage_session.py b/tests/test_update_triage_session.py index 6ffe8a4..a568d92 100644 --- a/tests/test_update_triage_session.py +++ b/tests/test_update_triage_session.py @@ -81,6 +81,27 @@ process.stdout.write(JSON.stringify({opened, progress, stored:values.get('stackc } +def test_update_triage_exposes_only_the_next_surviving_snapshot_item(): + result = run_session(""" +const values = new Map(); +let items = [1,2,3].map(notification_id => ({notification_id})); +const session = createSession({ + storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}, + getLogin:()=> 'timmy', getItems:()=>items, + onOpen:()=>{}, onProgress:()=>{}, onFinish:()=>{}, +}); +session.start(); +const first = session.next()?.notification_id; +items = [1,3,4].map(notification_id => ({notification_id})); +const afterRemoval = session.next()?.notification_id; +session.keepUnreadAndNext(); +const afterAdvance = session.next(); +process.stdout.write(JSON.stringify({first, afterRemoval, afterAdvance:afterAdvance || null})); +""") + + assert result == {"first": 2, "afterRemoval": 3, "afterAdvance": None} + + @pytest.mark.anyio async def test_dashboard_wires_resumable_updates_triage_mobile_flow(): html = await dashboard()