feat: prefetch the next mobile update (Closes #757)
All checks were successful
CI / lint (pull_request) Successful in 1m33s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-13 17:31:20 +00:00
parent 1a3df5d7a7
commit db08c7e75f
7 changed files with 141 additions and 9 deletions

View File

@ -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,

View File

@ -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');
},

View File

@ -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) {

View File

@ -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,
};

View File

@ -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

View File

@ -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))});

View File

@ -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()