feat: prefetch the next mobile update (Closes #757)
This commit is contained in:
parent
1a3df5d7a7
commit
db08c7e75f
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))});
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user