diff --git a/frontend/dashboard.js b/frontend/dashboard.js index d16a41d..4463ac8 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -8614,4 +8614,5 @@ function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); } setInterval(widgetTick, 1000); + workspaceLifecycle.markWorkspaceReady?.(); })(); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 873b1ae..9745a8e 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -301,6 +301,8 @@ const SHELL = [ ]; const OPTIONAL_FEATURES = [ ]; +const DEMAND_FEATURES = [ +]; const SHARED_IMAGE_ID = 'shared-image'; const SHARED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']); @@ -1027,7 +1029,8 @@ self.addEventListener('fetch', event => { event.respondWith(caches.match(request).then(cached => cached || fetch(request))); return; } - if (url.origin === self.location.origin && OPTIONAL_FEATURES.includes(url.pathname)) { + if (url.origin === self.location.origin && + (OPTIONAL_FEATURES.includes(url.pathname) || DEMAND_FEATURES.includes(url.pathname))) { event.respondWith(cachedOptionalFeature(request)); } }); diff --git a/frontend/workspace-bootstrap.js b/frontend/workspace-bootstrap.js index 45b428d..b19102b 100644 --- a/frontend/workspace-bootstrap.js +++ b/frontend/workspace-bootstrap.js @@ -19,6 +19,15 @@ async function loadWorkspace({ const failed = new Set(); const recoveries = new Map(); let retryInFlight = null; + let resolveWorkspaceReady; + const workspaceReady = new Promise(resolve => { resolveWorkspaceReady = resolve; }); + let workspaceMarkedReady = false; + const markWorkspaceReady = () => { + if (workspaceMarkedReady) return false; + workspaceMarkedReady = true; + resolveWorkspaceReady(true); + return true; + }; const loadFeature = name => { attempts[name] += 1; @@ -73,23 +82,55 @@ async function loadWorkspace({ } hideRecovery(); - const optional = ['today-timer', 'planning'].map(async name => { - try { - await retryOnce(name); - return true; - } catch (_error) { - failed.add(name); - showRecovery(); - return new Promise(resolve => recoveries.set(name, {resolve})); - } - }); - const optionalReady = Promise.all(optional).then(() => { - hideRecovery(); - return true; - }); + let optionalReady = null; + const hydrateWorkspace = () => { + if (optionalReady) return optionalReady; + let resolveOptional; + optionalReady = new Promise(resolve => { resolveOptional = resolve; }); + const optional = ['today-timer', 'planning'].map(async name => { + try { + await retryOnce(name); + return true; + } catch (_error) { + failed.add(name); + showRecovery(); + return new Promise(resolve => recoveries.set(name, {resolve})); + } + }); + Promise.all(optional).then(() => { + hideRecovery(); + resolveOptional(true); + }); + return optionalReady; + }; + + const hydrationSelector = [ + '[data-mobile-task]:not([data-mobile-task="work"]):not([data-mobile-task="queues"])', + '[data-progressive-loading="true"]', + '#app-menu-toggle', + '#work-settings-toggle', + ].join(','); + const hydrateForAction = async event => { + const target = event.target?.closest?.(hydrationSelector); + if (!target) return; + event.preventDefault(); + event.stopImmediatePropagation(); + await hydrateWorkspace(); + await workspaceReady; + document.removeEventListener?.('click', hydrateForAction, true); + target.click?.(); + }; + document.addEventListener?.('click', hydrateForAction, true); + const hash = window?.location?.hash || ''; + const deepLinkReady = hash.startsWith('#/') && hash !== '#/my-work' ? + hydrateWorkspace() : Promise.resolve(false); return { - optionalReady, + hydrateWorkspace, + deepLinkReady, + workspaceReady, + markWorkspaceReady, + get optionalReady() { return hydrateWorkspace(); }, retryFeature(name) { if (!failed.has(name)) return Promise.resolve(true); return retryFailed().then(() => !failed.has(name)); diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index 07d382c..c9ea018 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -140,7 +140,16 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild: for source in sources: if source != WORKER_RUNTIME_SOURCE: worker = worker.replace(f" BASE + '{source}',\n", "") - optional_features = feature_bundles + # The workspace hydrator fetches these large chunks only when a route or action + # needs them; warming them here would defeat demand loading on every visit. + optional_features = { + name: bundle for name, bundle in feature_bundles.items() + if name not in {"today-timer", "planning"} + } + demand_features = { + name: bundle for name, bundle in feature_bundles.items() + if name in {"today-timer", "planning"} + } worker = worker.replace( " BASE + 'static/dashboard.css',\n", " BASE + 'static/dashboard.css',\n" @@ -151,6 +160,11 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild: "const OPTIONAL_FEATURES = [\n" + "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in optional_features.values()), ) + worker = worker.replace( + "const DEMAND_FEATURES = [\n", + "const DEMAND_FEATURES = [\n" + + "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in demand_features.values()), + ) worker = CACHE_DECLARATION.sub( "const CACHE = 'stackchain-dashboard-shell-BUILD';", worker, count=1 ) diff --git a/tests/e2e/test_mobile_home_bootstrap_release.py b/tests/e2e/test_mobile_home_bootstrap_release.py index 96d462c..c430cdf 100644 --- a/tests/e2e/test_mobile_home_bootstrap_release.py +++ b/tests/e2e/test_mobile_home_bootstrap_release.py @@ -41,6 +41,10 @@ def test_release_artifact_guides_an_empty_mobile_account_to_first_work( page.locator('input[name="access_token"]').fill(ACCESS_TOKEN) page.locator("#submit-sign-in").click() page.wait_for_url(origin + "/", wait_until="networkidle") + page.evaluate( + "async () => { const lifecycle = await window.stackchainWorkspaceLifecycle; " + "await lifecycle.hydrateWorkspace(); await lifecycle.workspaceReady; }" + ) expect(page.locator("#my-work-status")).to_contain_text("No assigned work") page.locator('[data-mobile-task="work"]').click() @@ -100,7 +104,12 @@ def test_release_artifact_guides_an_empty_mobile_account_to_first_work( fresh_page.locator('input[name="access_token"]').fill(ACCESS_TOKEN) fresh_page.locator("#submit-sign-in").click() fresh_page.wait_for_url(origin + "/", wait_until="networkidle") + fresh_page.evaluate( + "async () => { const lifecycle = await window.stackchainWorkspaceLifecycle; " + "await lifecycle.hydrateWorkspace(); await lifecycle.workspaceReady; }" + ) expect(fresh_page.locator("#my-work-status")).to_contain_text("No assigned work") + fresh_page.wait_for_load_state("networkidle") assert fresh_page.evaluate( "localStorage.getItem('stackchain.first-task.v1:timmy')" ) == "complete" @@ -128,6 +137,7 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights( browser_errors: list[str] = [] failed_responses: list[str] = [] workspace_requests: list[str] = [] + optional_workspace_requests: list[str] = [] launch_transfer_events: list[str] = [] live_requests: list[str] = [] @@ -171,6 +181,12 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights( ) if "feature-work-core-" in request.url else None, ) + page.on( + "request", + lambda request: optional_workspace_requests.append(request.url) + if "feature-today-timer-" in request.url or "feature-planning-" in request.url + else None, + ) page.on( "requestfinished", lambda request: launch_transfer_events.append("core-finished") @@ -184,6 +200,7 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights( ) page.goto(origin + "/", wait_until="networkidle") + assert optional_workspace_requests == [] page.locator('input[name="device_label"]').fill("Home bootstrap release phone") page.locator('input[name="access_token"]').fill(ACCESS_TOKEN) live_requests.clear() @@ -194,9 +211,24 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights( ), launch_transfer_events expect(page.locator("#my-work-status")).to_contain_text("2") + assert optional_workspace_requests == [] 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.locator("#work-settings-toggle").click() + tomorrow = page.locator("#plan-tomorrow") + expect(tomorrow).to_be_visible() + assert len(optional_workspace_requests) == 2, optional_workspace_requests + tomorrow_bounds = tomorrow.bounding_box() + assert tomorrow_bounds and tomorrow_bounds["height"] >= 44 + tomorrow.click() + expect(page.locator("#plan-today-sheet")).to_be_visible() + expect(page.locator("#plan-today-title")).to_have_text("Plan Tomorrow") + expect(page.locator("#save-and-start-today")).to_be_hidden() + assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") + page.locator("#cancel-plan-today").click() + page.evaluate( """ () => { @@ -226,18 +258,6 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights( bounds = control.bounding_box() assert bounds and bounds["height"] >= 44 - page.locator("#work-settings-toggle").click() - tomorrow = page.locator("#plan-tomorrow") - expect(tomorrow).to_be_visible() - tomorrow_bounds = tomorrow.bounding_box() - assert tomorrow_bounds and tomorrow_bounds["height"] >= 44 - tomorrow.click() - expect(page.locator("#plan-today-sheet")).to_be_visible() - expect(page.locator("#plan-today-title")).to_have_text("Plan Tomorrow") - expect(page.locator("#save-and-start-today")).to_be_hidden() - assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") - page.locator("#cancel-plan-today").click() - page.locator('[data-mobile-task="queues"]').click() page.locator(".mobile-queue-all summary").click() delivery_queue = page.locator('[data-mobile-queue="delivery"]') @@ -361,6 +381,7 @@ def test_release_artifact_recovers_a_transient_workspace_request_in_place(tmp_pa page.locator('input[name="access_token"]').fill(ACCESS_TOKEN) page.locator("#submit-sign-in").click() page.wait_for_url(origin + "/", wait_until="networkidle") + page.locator('[data-mobile-task="find"]').click() page.wait_for_timeout(1500) resources = page.evaluate( @@ -406,6 +427,10 @@ def test_release_artifact_keeps_mobile_delivery_recovery_single_flight(tmp_path: page.locator('input[name="access_token"]').fill(ACCESS_TOKEN) page.locator("#submit-sign-in").click() page.wait_for_url(origin + "/", wait_until="networkidle") + page.evaluate( + "async () => { const lifecycle = await window.stackchainWorkspaceLifecycle; " + "await lifecycle.hydrateWorkspace(); await lifecycle.workspaceReady; }" + ) page.evaluate( """ @@ -508,6 +533,10 @@ def test_release_artifact_reviews_and_downloads_mobile_agenda_snapshot( page.locator('input[name="access_token"]').fill(ACCESS_TOKEN) page.locator("#submit-sign-in").click() page.wait_for_url(origin + "/", wait_until="networkidle") + page.evaluate( + "async () => { const lifecycle = await window.stackchainWorkspaceLifecycle; " + "await lifecycle.hydrateWorkspace(); await lifecycle.workspaceReady; }" + ) page.locator('[data-mobile-task="queues"]').click() page.locator('[data-mobile-queue="agenda"]').click() diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py index 65e336c..c56de5a 100644 --- a/tests/test_frontend_bundle.py +++ b/tests/test_frontend_bundle.py @@ -33,6 +33,15 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path): assert first.runtime_name.startswith("runtime-") assert first.runtime_name.endswith(".js") assert len(gzip.compress(first.runtime_bytes, mtime=0)) <= 100 * 1024 + initial_mobile_javascript = ( + len(first.runtime_gzip_bytes) + + len(first.feature_bundles["work-core"].runtime_gzip_bytes) + ) + assert initial_mobile_javascript <= 60 * 1024 + assert ( + len(first.feature_bundles["today-timer"].runtime_gzip_bytes) + + len(first.feature_bundles["planning"].runtime_gzip_bytes) + ) > initial_mobile_javascript changed_frontend = tmp_path / "frontend" shutil.copytree(FRONTEND, changed_frontend) @@ -100,9 +109,13 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path): ) optional_block = optional_block.split("];", 1)[0] assert f"BASE + '{first.feature_bundles['today-timer'].runtime_name}'" not in shell_block + demand_loaded = {"today-timer", "planning"} for name, bundle in first.feature_bundles.items(): assert f"BASE + '{bundle.runtime_name}'" not in shell_block - assert f"BASE + '{bundle.runtime_name}'" in optional_block + if name in demand_loaded: + assert f"BASE + '{bundle.runtime_name}'" not in optional_block + else: + assert f"BASE + '{bundle.runtime_name}'" in optional_block changed_frontend = tmp_path / "frontend" shutil.copytree(FRONTEND, changed_frontend) diff --git a/tests/test_workspace_bootstrap.py b/tests/test_workspace_bootstrap.py index a740b44..ee1481c 100644 --- a/tests/test_workspace_bootstrap.py +++ b/tests/test_workspace_bootstrap.py @@ -19,7 +19,7 @@ const loadWorkspace = require({json.dumps(str(BOOTSTRAP))}); return json.loads(completed.stdout) -def test_workspace_bootstrap_loads_content_addressed_feature_before_startup(): +def test_workspace_bootstrap_demand_loads_optional_features_after_work_core(): result = run_bootstrap(""" const status={textContent:''}; const document={ @@ -33,11 +33,14 @@ const document={ }; const requested=[]; const createLoader=options=>({load:async name=>{requested.push(name + ':' + options.urls[name]);}}); -await loadWorkspace({document,createLoader}); -console.log(JSON.stringify({requested,status:status.textContent})); +const lifecycle=await loadWorkspace({document,createLoader}); +const before=requested.slice(); +await lifecycle.hydrateWorkspace?.(); +console.log(JSON.stringify({before,after:requested,status:status.textContent})); """) assert result == { - "requested": [ + "before": ["work-core:feature-work-core-123.js"], + "after": [ "work-core:feature-work-core-123.js", "today-timer:feature-workspace-abc.js", "planning:feature-planning-def.js", @@ -46,7 +49,65 @@ console.log(JSON.stringify({requested,status:status.textContent})); } -def test_workspace_bootstrap_returns_after_work_core_while_optional_features_hydrate(): +def test_workspace_bootstrap_hydrates_once_and_replays_dependent_mobile_action(): + result = run_bootstrap(""" +const listeners={}; const requested=[]; +const document={ + querySelector(selector) { + const match=selector.match(/stackchain-feature-([^\"]+)/); + return match ? {content:'feature-' + match[1] + '.js'} : null; + }, + addEventListener(name,callback,capture){listeners[name]={callback,capture};}, + removeEventListener(name,callback,capture){ + if(listeners[name]?.callback===callback && listeners[name]?.capture===capture) delete listeners[name]; + }, +}; +const createLoader=()=>({load:async name=>{ + requested.push(name); + if(name==='today-timer') setImmediate(()=>{ + actionReady=true; + lifecycle.markWorkspaceReady?.(); + }); +}}); +let lifecycle=await loadWorkspace({document,createLoader}); +let prevented=0,stopped=0,replayed=0,actionReady=false,replayedReady=false; +const target={ + closest(selector){return selector.includes('data-mobile-task') ? this : null;}, + click(){replayed++; replayedReady=actionReady;}, +}; +await listeners.click?.callback({target,preventDefault(){prevented++;},stopImmediatePropagation(){stopped++;}}); +console.log(JSON.stringify({requested,prevented,stopped,replayed,replayedReady,listening:Boolean(listeners.click)})); +""") + assert result == { + "requested": ["work-core", "today-timer", "planning"], + "prevented": 1, + "stopped": 1, + "replayed": 1, + "replayedReady": True, + "listening": False, + } + + +def test_workspace_bootstrap_hydrates_immediately_for_deep_link(): + result = run_bootstrap(""" +const requested=[]; +const document={ + querySelector(selector) { + const match=selector.match(/stackchain-feature-([^\"]+)/); + return match ? {content:'feature-' + match[1] + '.js'} : null; + }, + addEventListener(){}, +}; +const window={location:{hash:'#/my-work/today'},addEventListener(){},removeEventListener(){}}; +const createLoader=()=>({load:async name=>{requested.push(name);}}); +const lifecycle=await loadWorkspace({document,window,createLoader}); +await lifecycle.deepLinkReady; +console.log(JSON.stringify({requested})); +""") + assert result == {"requested": ["work-core", "today-timer", "planning"]} + + +def test_workspace_bootstrap_returns_after_work_core_before_optional_features_hydrate(): result = run_bootstrap(""" const document={querySelector(selector) { const match=selector.match(/stackchain-feature-([^\"]+)/); @@ -60,12 +121,13 @@ const createLoader=()=>({load:name=>{ }}); const lifecycle=await loadWorkspace({document,createLoader}); const returned=requested.slice(); +const hydration=lifecycle.hydrateWorkspace(); releases['today-timer'](); releases.planning(); -await lifecycle.optionalReady; +await hydration; console.log(JSON.stringify({returned,settled:requested})); """) assert result == { - "returned": ["work-core", "today-timer", "planning"], + "returned": ["work-core"], "settled": ["work-core", "today-timer", "planning"], } @@ -81,7 +143,8 @@ const document={querySelector(selector) { }}; const createLoader=()=>({load:async()=>{attempts++; if (attempts === 1) throw new Error('brief outage');}}); const schedule=callback=>{callback();}; -await loadWorkspace({document,createLoader,schedule}); +const lifecycle=await loadWorkspace({document,createLoader,schedule}); +await lifecycle.hydrateWorkspace(); console.log(JSON.stringify({attempts,status:status.textContent,retryHidden:retry.hidden})); """) assert result == {"attempts": 4, "status": "", "retryHidden": True} @@ -107,7 +170,7 @@ await Promise.all([first,second,loading]); console.log(JSON.stringify({attempts,reloads,offered,status:status.textContent,retryHidden:retry.hidden})); """) assert result == { - "attempts": 5, + "attempts": 3, "reloads": 0, "offered": { "hidden": False, @@ -151,7 +214,7 @@ await loading; console.log(JSON.stringify({attempts,waiting,reloads,status:status.textContent})); """) assert result == { - "attempts": 5, + "attempts": 3, "waiting": True, "reloads": 0, "status": "",