perf: demand-load mobile workspace features (Closes #1468)
This commit is contained in:
parent
2a44cf20fc
commit
5d9cec1dd7
|
|
@ -8614,4 +8614,5 @@
|
|||
|
||||
function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); }
|
||||
setInterval(widgetTick, 1000);
|
||||
workspaceLifecycle.markWorkspaceReady?.();
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,6 +82,11 @@ async function loadWorkspace({
|
|||
}
|
||||
hideRecovery();
|
||||
|
||||
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);
|
||||
|
|
@ -83,13 +97,40 @@ async function loadWorkspace({
|
|||
return new Promise(resolve => recoveries.set(name, {resolve}));
|
||||
}
|
||||
});
|
||||
const optionalReady = Promise.all(optional).then(() => {
|
||||
Promise.all(optional).then(() => {
|
||||
hideRecovery();
|
||||
return true;
|
||||
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));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,8 +109,12 @@ 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
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user