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()); }
|
function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); }
|
||||||
setInterval(widgetTick, 1000);
|
setInterval(widgetTick, 1000);
|
||||||
|
workspaceLifecycle.markWorkspaceReady?.();
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -301,6 +301,8 @@ const SHELL = [
|
||||||
];
|
];
|
||||||
const OPTIONAL_FEATURES = [
|
const OPTIONAL_FEATURES = [
|
||||||
];
|
];
|
||||||
|
const DEMAND_FEATURES = [
|
||||||
|
];
|
||||||
|
|
||||||
const SHARED_IMAGE_ID = 'shared-image';
|
const SHARED_IMAGE_ID = 'shared-image';
|
||||||
const SHARED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
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)));
|
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
|
||||||
return;
|
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));
|
event.respondWith(cachedOptionalFeature(request));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,15 @@ async function loadWorkspace({
|
||||||
const failed = new Set();
|
const failed = new Set();
|
||||||
const recoveries = new Map();
|
const recoveries = new Map();
|
||||||
let retryInFlight = null;
|
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 => {
|
const loadFeature = name => {
|
||||||
attempts[name] += 1;
|
attempts[name] += 1;
|
||||||
|
|
@ -73,23 +82,55 @@ async function loadWorkspace({
|
||||||
}
|
}
|
||||||
hideRecovery();
|
hideRecovery();
|
||||||
|
|
||||||
const optional = ['today-timer', 'planning'].map(async name => {
|
let optionalReady = null;
|
||||||
try {
|
const hydrateWorkspace = () => {
|
||||||
await retryOnce(name);
|
if (optionalReady) return optionalReady;
|
||||||
return true;
|
let resolveOptional;
|
||||||
} catch (_error) {
|
optionalReady = new Promise(resolve => { resolveOptional = resolve; });
|
||||||
failed.add(name);
|
const optional = ['today-timer', 'planning'].map(async name => {
|
||||||
showRecovery();
|
try {
|
||||||
return new Promise(resolve => recoveries.set(name, {resolve}));
|
await retryOnce(name);
|
||||||
}
|
return true;
|
||||||
});
|
} catch (_error) {
|
||||||
const optionalReady = Promise.all(optional).then(() => {
|
failed.add(name);
|
||||||
hideRecovery();
|
showRecovery();
|
||||||
return true;
|
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 {
|
return {
|
||||||
optionalReady,
|
hydrateWorkspace,
|
||||||
|
deepLinkReady,
|
||||||
|
workspaceReady,
|
||||||
|
markWorkspaceReady,
|
||||||
|
get optionalReady() { return hydrateWorkspace(); },
|
||||||
retryFeature(name) {
|
retryFeature(name) {
|
||||||
if (!failed.has(name)) return Promise.resolve(true);
|
if (!failed.has(name)) return Promise.resolve(true);
|
||||||
return retryFailed().then(() => !failed.has(name));
|
return retryFailed().then(() => !failed.has(name));
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,16 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||||
for source in sources:
|
for source in sources:
|
||||||
if source != WORKER_RUNTIME_SOURCE:
|
if source != WORKER_RUNTIME_SOURCE:
|
||||||
worker = worker.replace(f" BASE + '{source}',\n", "")
|
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(
|
worker = worker.replace(
|
||||||
" BASE + 'static/dashboard.css',\n",
|
" BASE + 'static/dashboard.css',\n",
|
||||||
" 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"
|
"const OPTIONAL_FEATURES = [\n"
|
||||||
+ "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in optional_features.values()),
|
+ "".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(
|
worker = CACHE_DECLARATION.sub(
|
||||||
"const CACHE = 'stackchain-dashboard-shell-BUILD';", worker, count=1
|
"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('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
page.locator("#submit-sign-in").click()
|
page.locator("#submit-sign-in").click()
|
||||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
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")
|
expect(page.locator("#my-work-status")).to_contain_text("No assigned work")
|
||||||
|
|
||||||
page.locator('[data-mobile-task="work"]').click()
|
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('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
fresh_page.locator("#submit-sign-in").click()
|
fresh_page.locator("#submit-sign-in").click()
|
||||||
fresh_page.wait_for_url(origin + "/", wait_until="networkidle")
|
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")
|
expect(fresh_page.locator("#my-work-status")).to_contain_text("No assigned work")
|
||||||
|
fresh_page.wait_for_load_state("networkidle")
|
||||||
assert fresh_page.evaluate(
|
assert fresh_page.evaluate(
|
||||||
"localStorage.getItem('stackchain.first-task.v1:timmy')"
|
"localStorage.getItem('stackchain.first-task.v1:timmy')"
|
||||||
) == "complete"
|
) == "complete"
|
||||||
|
|
@ -128,6 +137,7 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||||
browser_errors: list[str] = []
|
browser_errors: list[str] = []
|
||||||
failed_responses: list[str] = []
|
failed_responses: list[str] = []
|
||||||
workspace_requests: list[str] = []
|
workspace_requests: list[str] = []
|
||||||
|
optional_workspace_requests: list[str] = []
|
||||||
launch_transfer_events: list[str] = []
|
launch_transfer_events: list[str] = []
|
||||||
live_requests: 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,
|
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(
|
page.on(
|
||||||
"requestfinished",
|
"requestfinished",
|
||||||
lambda request: launch_transfer_events.append("core-finished")
|
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")
|
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="device_label"]').fill("Home bootstrap release phone")
|
||||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
live_requests.clear()
|
live_requests.clear()
|
||||||
|
|
@ -194,9 +211,24 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||||
), launch_transfer_events
|
), launch_transfer_events
|
||||||
|
|
||||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
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]
|
revisionless_live_requests = [url for url in live_requests if "?" not in url]
|
||||||
assert len(revisionless_live_requests) == 1, live_requests
|
assert len(revisionless_live_requests) == 1, live_requests
|
||||||
initial_live_requests = len(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(
|
page.evaluate(
|
||||||
"""
|
"""
|
||||||
() => {
|
() => {
|
||||||
|
|
@ -226,18 +258,6 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||||
bounds = control.bounding_box()
|
bounds = control.bounding_box()
|
||||||
assert bounds and bounds["height"] >= 44
|
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('[data-mobile-task="queues"]').click()
|
||||||
page.locator(".mobile-queue-all summary").click()
|
page.locator(".mobile-queue-all summary").click()
|
||||||
delivery_queue = page.locator('[data-mobile-queue="delivery"]')
|
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('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
page.locator("#submit-sign-in").click()
|
page.locator("#submit-sign-in").click()
|
||||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||||
|
page.locator('[data-mobile-task="find"]').click()
|
||||||
|
|
||||||
page.wait_for_timeout(1500)
|
page.wait_for_timeout(1500)
|
||||||
resources = page.evaluate(
|
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('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
page.locator("#submit-sign-in").click()
|
page.locator("#submit-sign-in").click()
|
||||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||||
|
page.evaluate(
|
||||||
|
"async () => { const lifecycle = await window.stackchainWorkspaceLifecycle; "
|
||||||
|
"await lifecycle.hydrateWorkspace(); await lifecycle.workspaceReady; }"
|
||||||
|
)
|
||||||
|
|
||||||
page.evaluate(
|
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('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
page.locator("#submit-sign-in").click()
|
page.locator("#submit-sign-in").click()
|
||||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
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-task="queues"]').click()
|
||||||
page.locator('[data-mobile-queue="agenda"]').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.startswith("runtime-")
|
||||||
assert first.runtime_name.endswith(".js")
|
assert first.runtime_name.endswith(".js")
|
||||||
assert len(gzip.compress(first.runtime_bytes, mtime=0)) <= 100 * 1024
|
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"
|
changed_frontend = tmp_path / "frontend"
|
||||||
shutil.copytree(FRONTEND, changed_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]
|
optional_block = optional_block.split("];", 1)[0]
|
||||||
assert f"BASE + '{first.feature_bundles['today-timer'].runtime_name}'" not in shell_block
|
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():
|
for name, bundle in first.feature_bundles.items():
|
||||||
assert f"BASE + '{bundle.runtime_name}'" not in shell_block
|
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"
|
changed_frontend = tmp_path / "frontend"
|
||||||
shutil.copytree(FRONTEND, changed_frontend)
|
shutil.copytree(FRONTEND, changed_frontend)
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ const loadWorkspace = require({json.dumps(str(BOOTSTRAP))});
|
||||||
return json.loads(completed.stdout)
|
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("""
|
result = run_bootstrap("""
|
||||||
const status={textContent:''};
|
const status={textContent:''};
|
||||||
const document={
|
const document={
|
||||||
|
|
@ -33,11 +33,14 @@ const document={
|
||||||
};
|
};
|
||||||
const requested=[];
|
const requested=[];
|
||||||
const createLoader=options=>({load:async name=>{requested.push(name + ':' + options.urls[name]);}});
|
const createLoader=options=>({load:async name=>{requested.push(name + ':' + options.urls[name]);}});
|
||||||
await loadWorkspace({document,createLoader});
|
const lifecycle=await loadWorkspace({document,createLoader});
|
||||||
console.log(JSON.stringify({requested,status:status.textContent}));
|
const before=requested.slice();
|
||||||
|
await lifecycle.hydrateWorkspace?.();
|
||||||
|
console.log(JSON.stringify({before,after:requested,status:status.textContent}));
|
||||||
""")
|
""")
|
||||||
assert result == {
|
assert result == {
|
||||||
"requested": [
|
"before": ["work-core:feature-work-core-123.js"],
|
||||||
|
"after": [
|
||||||
"work-core:feature-work-core-123.js",
|
"work-core:feature-work-core-123.js",
|
||||||
"today-timer:feature-workspace-abc.js",
|
"today-timer:feature-workspace-abc.js",
|
||||||
"planning:feature-planning-def.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("""
|
result = run_bootstrap("""
|
||||||
const document={querySelector(selector) {
|
const document={querySelector(selector) {
|
||||||
const match=selector.match(/stackchain-feature-([^\"]+)/);
|
const match=selector.match(/stackchain-feature-([^\"]+)/);
|
||||||
|
|
@ -60,12 +121,13 @@ const createLoader=()=>({load:name=>{
|
||||||
}});
|
}});
|
||||||
const lifecycle=await loadWorkspace({document,createLoader});
|
const lifecycle=await loadWorkspace({document,createLoader});
|
||||||
const returned=requested.slice();
|
const returned=requested.slice();
|
||||||
|
const hydration=lifecycle.hydrateWorkspace();
|
||||||
releases['today-timer'](); releases.planning();
|
releases['today-timer'](); releases.planning();
|
||||||
await lifecycle.optionalReady;
|
await hydration;
|
||||||
console.log(JSON.stringify({returned,settled:requested}));
|
console.log(JSON.stringify({returned,settled:requested}));
|
||||||
""")
|
""")
|
||||||
assert result == {
|
assert result == {
|
||||||
"returned": ["work-core", "today-timer", "planning"],
|
"returned": ["work-core"],
|
||||||
"settled": ["work-core", "today-timer", "planning"],
|
"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 createLoader=()=>({load:async()=>{attempts++; if (attempts === 1) throw new Error('brief outage');}});
|
||||||
const schedule=callback=>{callback();};
|
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}));
|
console.log(JSON.stringify({attempts,status:status.textContent,retryHidden:retry.hidden}));
|
||||||
""")
|
""")
|
||||||
assert result == {"attempts": 4, "status": "", "retryHidden": True}
|
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}));
|
console.log(JSON.stringify({attempts,reloads,offered,status:status.textContent,retryHidden:retry.hidden}));
|
||||||
""")
|
""")
|
||||||
assert result == {
|
assert result == {
|
||||||
"attempts": 5,
|
"attempts": 3,
|
||||||
"reloads": 0,
|
"reloads": 0,
|
||||||
"offered": {
|
"offered": {
|
||||||
"hidden": False,
|
"hidden": False,
|
||||||
|
|
@ -151,7 +214,7 @@ await loading;
|
||||||
console.log(JSON.stringify({attempts,waiting,reloads,status:status.textContent}));
|
console.log(JSON.stringify({attempts,waiting,reloads,status:status.textContent}));
|
||||||
""")
|
""")
|
||||||
assert result == {
|
assert result == {
|
||||||
"attempts": 5,
|
"attempts": 3,
|
||||||
"waiting": True,
|
"waiting": True,
|
||||||
"reloads": 0,
|
"reloads": 0,
|
||||||
"status": "",
|
"status": "",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user