From 54cb078d1a5b115dd6a3b8307f2514830335b833 Mon Sep 17 00:00:00 2001 From: timmy Date: Tue, 25 Aug 2026 19:13:23 +0000 Subject: [PATCH] perf: reuse progressive live snapshot during hydration (Closes #1405) --- frontend/context-poller.js | 38 ++++++++ frontend/dashboard.js | 7 +- frontend/progressive-my-work.js | 24 ++++- .../e2e/test_mobile_home_bootstrap_release.py | 3 + tests/test_context_polling.py | 85 ++++++++++++++++++ tests/test_mobile_launch.py | 8 +- tests/test_progressive_my_work.py | 88 ++++++++++++++++++- 7 files changed, 247 insertions(+), 6 deletions(-) diff --git a/frontend/context-poller.js b/frontend/context-poller.js index e98a043..c3f6b72 100644 --- a/frontend/context-poller.js +++ b/frontend/context-poller.js @@ -166,9 +166,47 @@ function createContextPoller({ return refresh({ force: true }); } + function adopt(snapshot) { + if ( + stopped || activeRequest || retainedSnapshot || !snapshot || + typeof snapshot !== 'object' || + !Object.prototype.hasOwnProperty.call(snapshot, 'context') + ) return false; + retainedSnapshot = { ...snapshot }; + revisions = { ...(snapshot.revisions || {}) }; + failureStreak = 0; + lastSuccessAt = Date.now(); + nextDelayMs = snapshotDelay(snapshot); + const changedSections = ['context', 'events', 'notifications'].filter( + section => Object.prototype.hasOwnProperty.call(snapshot, section) + ); + onSnapshot(retainedSnapshot, changedSections); + schedule(nextDelayMs); + return true; + } + + async function adoptPending(snapshotPromise) { + if (!snapshotPromise || typeof snapshotPromise.then !== 'function') return false; + let deadline = null; + const timeout = new Promise(resolve => { + deadline = setDeadlineTimer(() => resolve(null), timeoutMs); + }); + try { + const snapshot = await Promise.race([ + Promise.resolve(snapshotPromise).catch(() => null), + timeout, + ]); + return adopt(snapshot); + } finally { + clearDeadlineTimer(deadline); + } + } + return { start: refresh, refresh, + adopt, + adoptPending, setVisible, getState() { return { diff --git a/frontend/dashboard.js b/frontend/dashboard.js index caf2f85..98334d8 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -8441,9 +8441,12 @@ if (!deviceSetup) await (await ensureDeviceSetup()).open(event); }); pushControllerReady.then(ensureDeviceSetup).catch(console.warn); - await load(); + let adoptedProgressiveSnapshot = contextPoller.adopt(progressiveWorkHandoff?.liveSnapshot); + if (!adoptedProgressiveSnapshot && progressiveWorkHandoff?.liveSnapshotPromise) { + adoptedProgressiveSnapshot = await contextPoller.adoptPending(progressiveWorkHandoff.liveSnapshotPromise); + } + if (!adoptedProgressiveSnapshot) await load(); await appShortcut.run(); - contextPoller.start(); document.addEventListener('visibilitychange', () => { contextPoller.setVisible(!document.hidden); if (!document.hidden) deviceSetup?.render(); diff --git a/frontend/progressive-my-work.js b/frontend/progressive-my-work.js index cbecab9..eb815a7 100644 --- a/frontend/progressive-my-work.js +++ b/frontend/progressive-my-work.js @@ -7,6 +7,8 @@ function createProgressiveMyWork({ document, fetchSnapshot }) { let active = 'all'; let stopped = false; let selectedByUser = false; + let liveSnapshot = null; + let liveSnapshotPromise = null; const deferredQueues = { today:'Today', agenda:'Agenda', later:'Later', draft:'Drafts', }; @@ -59,13 +61,31 @@ function createProgressiveMyWork({ document, fetchSnapshot }) { return { handoff() { - return { selectedFilter:selectedByUser ? active : null }; + const state = { selectedFilter:selectedByUser ? active : null }; + if (liveSnapshot) { + state.liveSnapshot = liveSnapshot; + liveSnapshot = null; + liveSnapshotPromise = null; + } else if (liveSnapshotPromise) { + state.liveSnapshotPromise = liveSnapshotPromise; + liveSnapshotPromise = null; + } + return state; }, async start() { if (status) status.textContent = 'Loading assigned work…'; + const request = Promise.resolve().then(() => fetchSnapshot()); + liveSnapshotPromise = request.then(snapshot => ( + snapshot && typeof snapshot === 'object' && + Object.prototype.hasOwnProperty.call(snapshot, 'context') ? snapshot : null + ), () => null); try { - const snapshot = await fetchSnapshot(); + 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 || {}; items = buildMyWork({ ...context, notifications:snapshot?.notifications || context.notifications || [] }); updateCounts(); diff --git a/tests/e2e/test_mobile_home_bootstrap_release.py b/tests/e2e/test_mobile_home_bootstrap_release.py index 4f5b6b2..c2f7f76 100644 --- a/tests/e2e/test_mobile_home_bootstrap_release.py +++ b/tests/e2e/test_mobile_home_bootstrap_release.py @@ -186,6 +186,7 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights( page.goto(origin + "/", wait_until="networkidle") page.locator('input[name="device_label"]').fill("Home bootstrap release phone") page.locator('input[name="access_token"]').fill(ACCESS_TOKEN) + live_requests.clear() page.locator("#submit-sign-in").click() page.wait_for_url(origin + "/", wait_until="networkidle") assert launch_transfer_events.index("workspace-requested") < ( @@ -193,6 +194,8 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights( ), launch_transfer_events expect(page.locator("#my-work-status")).to_contain_text("2") + revisionless_live_requests = [url for url in live_requests if "?" not in url] + assert len(revisionless_live_requests) == 1, live_requests initial_live_requests = len(live_requests) page.evaluate( """ diff --git a/tests/test_context_polling.py b/tests/test_context_polling.py index 72b9561..11ff4bd 100644 --- a/tests/test_context_polling.py +++ b/tests/test_context_polling.py @@ -117,6 +117,91 @@ const poller = createContextPoller({{ } +def test_context_poller_adopts_progressive_snapshot_before_revision_conditional_refresh(): + script = f""" +const createContextPoller = require({json.dumps(str(POLLER))}); +const requested = []; +const rendered = []; +const timers = []; +const seed = {{ + context:{{user:{{login:'timmy'}}}}, events:[{{id:7}}], 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:5}},notifications:{{degraded:false,age_seconds:1}}, + }}}}, +}}; +const poller = createContextPoller({{ + fetchContext: revisions => {{ + requested.push({{...revisions}}); + return Promise.resolve({{revisions:{{...revisions}},freshness:seed.freshness}}); + }}, + onSnapshot: (snapshot, changed) => rendered.push({{login:snapshot.context.user.login,changed}}), + onError: error => {{ throw error; }}, + setTimer: (callback, delay) => {{ const timer={{callback,delay}}; timers.push(timer); return timer; }}, + clearTimer: () => {{}}, + intervalMs:8000, +}}); +(async()=>{{ + const adopted=poller.adopt(seed); + const before={{adopted,requests:requested.length,rendered:[...rendered],delay:timers[0].delay}}; + timers[0].callback(); + await new Promise(resolve=>setImmediate(resolve)); + process.stdout.write(JSON.stringify({{before,requested}})); +}})(); +""" + + assert run_node(script) == { + "before": { + "adopted": True, + "requests": 0, + "rendered": [{ + "login": "timmy", + "changed": ["context", "events", "notifications"], + }], + "delay": 3000, + }, + "requested": [{ + "context": "0123456789abcdef.1", + "events": "fedcba9876543210.2", + "notifications": "0011223344556677.3", + }], + } + + +def test_context_poller_adopts_inflight_progressive_snapshot_without_fetching(): + script = f""" +const createContextPoller = require({json.dumps(str(POLLER))}); +let calls=0; const rendered=[]; const timers=[]; +const snapshot={{context:{{user:{{login:'timmy'}}}},events:[],notifications:[]}}; +const poller=createContextPoller({{ + fetchContext:()=>{{calls+=1;return Promise.resolve(snapshot);}}, + onSnapshot:value=>rendered.push(value.context.user.login), + onError:error=>{{throw error;}}, + setTimer:(callback,delay)=>{{const timer={{callback,delay}};timers.push(timer);return timer;}}, + clearTimer:()=>{{}}, setDeadlineTimer:()=>1, clearDeadlineTimer:()=>{{}}, intervalMs:8000, +}}); +(async()=>{{ + const adopted=await poller.adoptPending(Promise.resolve(snapshot)); + process.stdout.write(JSON.stringify({{adopted,calls,rendered,delay:timers[0].delay}})); +}})(); +""" + + assert run_node(script) == { + "adopted": True, + "calls": 0, + "rendered": ["timmy"], + "delay": 8000, + } + + +def test_dashboard_adopts_progressive_snapshot_or_falls_back_to_immediate_load(): + source = DASHBOARD.read_text() + + assert "let adoptedProgressiveSnapshot = contextPoller.adopt(progressiveWorkHandoff?.liveSnapshot);" in source + assert "contextPoller.adoptPending(progressiveWorkHandoff.liveSnapshotPromise)" in source + assert "if (!adoptedProgressiveSnapshot) await load();" in source + + def test_context_poller_waits_for_server_cooldown_when_every_section_is_degraded(): script = f""" const createContextPoller = require({json.dumps(str(POLLER))}); diff --git a/tests/test_mobile_launch.py b/tests/test_mobile_launch.py index 388bff9..18d41d8 100644 --- a/tests/test_mobile_launch.py +++ b/tests/test_mobile_launch.py @@ -96,7 +96,13 @@ async def test_mobile_launch_progressively_discloses_secondary_controls(): assert "BASE + 'static/mobile-app-shortcuts.js'" in service_worker bootstrap = (Path(__file__).resolve().parents[1] / "frontend" / "dashboard.js").read_text() assert "mobileAppShortcuts.createController" in bootstrap - assert bootstrap.index("await load();\n await appShortcut.run();") < bootstrap.index("contextPoller.start();") + adopted = bootstrap.index( + "let adoptedProgressiveSnapshot = contextPoller.adopt(progressiveWorkHandoff?.liveSnapshot);" + ) + fallback = bootstrap.index("if (!adoptedProgressiveSnapshot) await load();") + shortcuts = bootstrap.index("await appShortcut.run();") + assert adopted < fallback < shortcuts + assert "contextPoller.start();" not in bootstrap assert "mobileLaunch.chooseFilter" in html assert "agenda: counts.agenda" in html assert "mobileLaunch.createDisclosure" in html diff --git a/tests/test_progressive_my_work.py b/tests/test_progressive_my_work.py index 247e5b4..ae8f8fd 100644 --- a/tests/test_progressive_my_work.py +++ b/tests/test_progressive_my_work.py @@ -133,4 +133,90 @@ const flow=context.module.exports({{document,fetchSnapshot:async()=>({{user:{{lo dashboard = DASHBOARD.read_text() assert "const progressiveWorkHandoff = window.stackchainProgressiveMyWork?.handoff?.();" in dashboard - assert "progressiveWorkHandoff?.selectedFilter" in dashboard \ No newline at end of file + assert "progressiveWorkHandoff?.selectedFilter" in dashboard + + +def test_progressive_live_snapshot_is_handed_to_the_workspace_exactly_once(): + harness = f""" +const fs=require('fs'); const vm=require('vm'); +const document={{ + querySelector:selector=>selector==='#my-work-list'?{{innerHTML:''}}:selector==='#my-work-status'?{{textContent:''}}:null, + querySelectorAll:()=>[], +}}; +const context={{module:{{exports:{{}}}},exports:{{}},console,URL,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(MODULE))},'utf8'),context); +const snapshot={{ + context:{{user:{{login:'timmy'}},issues:[],pull_requests:[]}}, + events:[{{id:7}}], 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 flow=context.module.exports({{document,fetchSnapshot:async()=>snapshot}}); +(async()=>{{ + await flow.start(); + const first=flow.handoff(); const second=flow.handoff(); + console.log(JSON.stringify({{ + firstSnapshot:first.liveSnapshot, + secondHasSnapshot:Object.prototype.hasOwnProperty.call(second,'liveSnapshot'), + }})); +}})().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 == {"firstSnapshot": { + "context": {"user": {"login": "timmy"}, "issues": [], "pull_requests": []}, + "events": [{"id": 7}], + "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}, + }, + }, + }, "secondHasSnapshot": False} + + +def test_progressive_inflight_snapshot_survives_workspace_handoff_and_stop(): + harness = f""" +const fs=require('fs'); const vm=require('vm'); +const document={{ + querySelector:selector=>selector==='#my-work-list'?{{innerHTML:''}}:selector==='#my-work-status'?{{textContent:''}}:null, + querySelectorAll:()=>[], +}}; +const context={{module:{{exports:{{}}}},exports:{{}},console,URL,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(MODULE))},'utf8'),context); +let resolveSnapshot; +const snapshot={{context:{{user:{{login:'timmy'}},issues:[],pull_requests:[]}},events:[],notifications:[]}}; +const flow=context.module.exports({{document,fetchSnapshot:()=>new Promise(resolve=>{{resolveSnapshot=resolve;}})}}); +(async()=>{{ + const started=flow.start(); + await Promise.resolve(); + const handoff=flow.handoff(); + flow.stop(); + resolveSnapshot(snapshot); + const transferred=await handoff.liveSnapshotPromise; + await started; + console.log(JSON.stringify({{transferred}})); +}})().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) == {"transferred": { + "context": {"user": {"login": "timmy"}, "issues": [], "pull_requests": []}, + "events": [], + "notifications": [], + }} \ No newline at end of file -- 2.43.0