From 297ae7550ab87a6afddedfc664b3f7b8fa46de12 Mon Sep 17 00:00:00 2001 From: timmy Date: Tue, 25 Aug 2026 16:35:05 +0000 Subject: [PATCH] feat: hydrate mobile My Work before optional tools Closes #1401 --- frontend/dashboard.js | 4 +- frontend/index.html | 1 + frontend/progressive-my-work.js | 81 +++++++++++ frontend/service-worker.js | 1 + frontend/workspace-bootstrap.js | 128 +++++++++++------- src/frontend_bundle.py | 7 +- .../e2e/test_mobile_home_bootstrap_release.py | 2 +- tests/test_frontend_bundle.py | 22 ++- tests/test_progressive_my_work.py | 38 ++++++ tests/test_service_worker.py | 1 + tests/test_workspace_bootstrap.py | 34 ++++- 11 files changed, 259 insertions(+), 60 deletions(-) create mode 100644 frontend/progressive-my-work.js create mode 100644 tests/test_progressive_my_work.py diff --git a/frontend/dashboard.js b/frontend/dashboard.js index a636e48..4dedbeb 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -1,5 +1,7 @@ (async function(){ - const workspaceLifecycle = await loadWorkspace({ document, window }); + const workspaceLifecycle = await (window.stackchainWorkspaceLifecycle || loadWorkspace({ document, window })); + await workspaceLifecycle.optionalReady; + window.stackchainProgressiveMyWork?.stop(); const qs = (s, el=document) => el.querySelector(s); const announceWork = message => qs('#my-work-action-status').textContent = message; const fmt = (d) => new Date(d).toLocaleString(); diff --git a/frontend/index.html b/frontend/index.html index 5900a92..84cfd90 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2284,6 +2284,7 @@ + diff --git a/frontend/progressive-my-work.js b/frontend/progressive-my-work.js new file mode 100644 index 0000000..3a9b413 --- /dev/null +++ b/frontend/progressive-my-work.js @@ -0,0 +1,81 @@ +function createProgressiveMyWork({ document, fetchSnapshot }) { + const list = document.querySelector('#my-work-list'); + const status = document.querySelector('#my-work-status'); + const filters = Array.from(document.querySelectorAll('[data-work-filter]')); + const listeners = []; + let items = []; + let active = 'all'; + let stopped = false; + + const escapeHtml = value => String(value || '').replace(/[&<>"']/g, character => ({ + '&':'&', '<':'<', '>':'>', '"':'"', "'":''', + })[character]); + const safeUrl = value => { + try { + const url = new URL(String(value || ''), globalThis.location?.href || 'https://invalid.example/'); + return ['http:', 'https:'].includes(url.protocol) ? url.href : ''; + } catch (_error) { return ''; } + }; + const visibleItems = () => active === 'all' ? items : items.filter(item => + active === 'review' ? item.is_review : + active === 'update' ? item.has_update : + active === 'attention' ? item.needs_attention : item.kind === active + ); + const render = () => { + if (stopped || !list) return; + const visible = visibleItems(); + list.innerHTML = visible.length ? visible.map(item => { + const href = safeUrl(item.url); + const title = escapeHtml(item.title || item.key || 'Untitled work'); + const context = escapeHtml(item.key || ''); + const reason = escapeHtml(item.reason || 'Assigned to you'); + return '
' + + (href ? '' : '') + '
'; + }).join('') : '
No work in this queue.
'; + filters.forEach(button => button.setAttribute('aria-pressed', String(button.dataset.workFilter === active))); + }; + filters.forEach(button => { + const listener = () => { active = button.dataset.workFilter || 'all'; render(); }; + button.addEventListener('click', listener); + listeners.push([button, listener]); + }); + + return { + async start() { + if (status) status.textContent = 'Loading assigned work…'; + try { + const snapshot = await fetchSnapshot(); + if (stopped) return false; + const context = snapshot?.context || snapshot || {}; + items = buildMyWork({ ...context, notifications:snapshot?.notifications || context.notifications || [] }); + render(); + const assigned = items.filter(item => item.is_assigned).length; + if (status) status.textContent = assigned + ' assigned work item' + (assigned === 1 ? '' : 's') + ' ready.'; + return true; + } catch (_error) { + if (status && !stopped) status.textContent = 'Assigned work is reconnecting…'; + return false; + } + }, + stop() { + stopped = true; + listeners.forEach(([button, listener]) => button.removeEventListener?.('click', listener)); + }, + }; +} + +if (typeof window !== 'undefined' && typeof document !== 'undefined') { + window.stackchainProgressiveMyWork = createProgressiveMyWork({ + document, + fetchSnapshot: async () => { + const response = await fetch('api/v1/live', { headers:{Accept:'application/json'} }); + if (!response.ok) throw new Error('HTTP ' + response.status); + return response.json(); + }, + }); + void window.stackchainProgressiveMyWork.start(); +} + +if (typeof module !== 'undefined' && module.exports) module.exports = createProgressiveMyWork; diff --git a/frontend/service-worker.js b/frontend/service-worker.js index f08ca32..fcd7961 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -186,6 +186,7 @@ const SHELL = [ BASE + 'static/offline-work.js', BASE + 'static/offline-today.js', BASE + 'static/my-work.js', + BASE + 'static/progressive-my-work.js', BASE + 'static/agenda-replan.js', BASE + 'static/agenda-calendar.js', BASE + 'static/protect-today.js', diff --git a/frontend/workspace-bootstrap.js b/frontend/workspace-bootstrap.js index b5fda93..45b428d 100644 --- a/frontend/workspace-bootstrap.js +++ b/frontend/workspace-bootstrap.js @@ -2,76 +2,110 @@ async function loadWorkspace({ document, window = null, createLoader = createFeatureLoader, - schedule = callback => setTimeout(callback,750), + schedule = callback => setTimeout(callback, 750), }) { let cameOnline = false; let replayed = false; - let retryInFlight = null; - const captureOnline = () => { cameOnline = true; }; - window?.addEventListener('online', captureOnline); + const status = document.querySelector('#my-work-action-status'); const retryButton = document.querySelector('#retry-workspace'); - const urls = Object.fromEntries(['today-timer', 'planning'].map(name => [name, + const names = ['work-core', 'today-timer', 'planning']; + const urls = Object.fromEntries(names.map(name => [name, document.querySelector(`meta[name="stackchain-feature-${name}"]`)?.content || '' ])); const originalUrls = {...urls}; const loader = createLoader({document, urls}); + const attempts = Object.fromEntries(names.map(name => [name, 0])); + const failed = new Set(); + const recoveries = new Map(); + let retryInFlight = null; - let attempts = 0; - const load = () => { - if (attempts++) Object.keys(urls).forEach(name => { - urls[name] = originalUrls[name] + '?retry=' + attempts; - }); - return Promise.all(Object.keys(urls).map(name => loader.load(name))); + const loadFeature = name => { + attempts[name] += 1; + urls[name] = originalUrls[name] + (attempts[name] > 1 ? '?retry=' + attempts[name] : ''); + return loader.load(name); }; - const waitForRecovery = () => new Promise(resolve => { - if (status) status.textContent = 'Workspace unavailable. Reconnect or retry.'; - if (retryButton) { - retryButton.hidden = retryButton.disabled = false; + const retryOnce = async name => { + try { return await loadFeature(name); } + catch (_error) { + await new Promise(resolve => schedule(resolve)); + return loadFeature(name); } - - const recover = () => { - if (retryInFlight) return retryInFlight; - if (retryButton) retryButton.disabled = true; - retryInFlight = load().then(() => { - window?.removeEventListener('online', recover); - resolve(); - }).catch(() => { - if (status) status.textContent = 'Workspace unavailable. Reconnect or retry.'; - if (retryButton) retryButton.disabled = false; - }).finally(() => { - retryInFlight = null; - }); - return retryInFlight; - }; - - window?.removeEventListener('online', captureOnline); - window?.addEventListener('online', recover); - retryButton?.addEventListener('click', recover); - }); + }; + const showRecovery = () => { + if (status) status.textContent = failed.has('work-core') ? + 'Workspace unavailable. Reconnect or retry.' : + 'Today or planning tools unavailable. My Work is ready; reconnect or retry.'; + if (retryButton) retryButton.hidden = retryButton.disabled = false; + }; + const hideRecovery = () => { + if (failed.size) return; + if (retryButton) retryButton.hidden = true; + if (status) status.textContent = ''; + }; + const retryFailed = () => { + if (retryInFlight) return retryInFlight; + if (retryButton) retryButton.disabled = true; + const pending = Array.from(failed); + retryInFlight = Promise.all(pending.map(async name => { + try { + await loadFeature(name); + failed.delete(name); + recoveries.get(name)?.resolve(true); + recoveries.delete(name); + } catch (_error) {} + })).then(() => { + if (failed.size) showRecovery(); + else hideRecovery(); + }).finally(() => { retryInFlight = null; }); + return retryInFlight; + }; + retryButton?.addEventListener?.('click', retryFailed); + const handleOnline = () => { cameOnline = true; void retryFailed(); }; + window?.addEventListener('online', handleOnline); try { - await load(); - } catch { - await new Promise(resolve => schedule(resolve)); - try { - await load(); - } catch { - await waitForRecovery(); - } + await retryOnce('work-core'); + } catch (_error) { + failed.add('work-core'); + showRecovery(); + await new Promise(resolve => recoveries.set('work-core', {resolve})); } + 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; + }); - window?.removeEventListener('online', captureOnline); - if (retryButton) retryButton.hidden = true; - if (status) status.textContent = ''; return { + optionalReady, + retryFeature(name) { + if (!failed.has(name)) return Promise.resolve(true); + return retryFailed().then(() => !failed.has(name)); + }, replayOnline(callback) { if (replayed) return; replayed = true; - window?.removeEventListener('online', captureOnline); + window?.removeEventListener('online', handleOnline); if (cameOnline) callback(); }, }; } +if (typeof window !== 'undefined' && typeof document !== 'undefined' && + typeof createFeatureLoader === 'function') { + window.stackchainWorkspaceLifecycle = loadWorkspace({document, window}); +} + if (typeof module !== 'undefined' && module.exports) module.exports = loadWorkspace; diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index 93bdcd6..51e8730 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -21,6 +21,7 @@ COMMONJS_BROWSER_BRANCH = re.compile( ) WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js" FEATURE_SOURCES = { + "work-core": ("static/my-work.js", "static/progressive-my-work.js"), "comment-actions": ("static/comment-actions.js",), "issue-capture": ( "static/voice-transcript-store.js", "static/voice-issue-capture.js", "static/create-issue-sheet.js", "static/create-pull-sheet.js", "static/mobile-create-issue-nav.js", "static/update-follow-up.js", "static/shared-image-capture.js", @@ -40,13 +41,13 @@ FEATURE_SOURCES = { ), "today-timer": ( "static/mobile-app-badge.js", "static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js", "static/mobile-search-modal.js", "static/mobile-composer-viewport.js", - "static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-first-task.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js", + "static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-first-task.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js", "static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/photo-draft-inbox.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js", "static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js", "static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js", "static/today-work.js", "static/today-sync.js", "static/pick-work.js", "static/batch-find-work.js", "static/mention-composer.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js", - "static/issue-attachment.js", "static/checklist-conflict.js", "static/issue-outbox.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/mobile-issue-detail-nav.js", "static/mobile-update-detail-nav.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js", + "static/issue-attachment.js", "static/checklist-conflict.js", "static/issue-outbox.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/mobile-issue-detail-nav.js", "static/mobile-update-detail-nav.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js", "static/dashboard.js", ), } CACHE_DECLARATION = re.compile( @@ -122,7 +123,7 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild: ) workspace_preload = ( f'' + f'href="{feature_bundles["work-core"].runtime_name}">' ) dashboard_html = dashboard_html.replace( "", feature_metadata + "\n" + workspace_preload + "\n" diff --git a/tests/e2e/test_mobile_home_bootstrap_release.py b/tests/e2e/test_mobile_home_bootstrap_release.py index 43179a6..4f5b6b2 100644 --- a/tests/e2e/test_mobile_home_bootstrap_release.py +++ b/tests/e2e/test_mobile_home_bootstrap_release.py @@ -169,7 +169,7 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights( workspace_requests.append(request.url), launch_transfer_events.append("workspace-requested"), ) - if "feature-today-timer-" in request.url else None, + if "feature-work-core-" in request.url else None, ) page.on( "requestfinished", diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py index 6769456..505dcd5 100644 --- a/tests/test_frontend_bundle.py +++ b/tests/test_frontend_bundle.py @@ -50,7 +50,7 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path): assert set(first.feature_bundles) == { "comment-actions", "issue-capture", "pull-workflow", "push-notifications", "sign-out", "device-setup", - "today-timer", "security-center", "planning", + "work-core", "today-timer", "security-center", "planning", } assert first.dashboard_html.count("' in first.dashboard_html @@ -95,7 +95,6 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path): assert f"BASE + '{pull_workflow.runtime_name}'" in first.service_worker_source assert f'name="stackchain-feature-security-center" content="{security_center.runtime_name}"' in first.dashboard_html assert f"BASE + '{security_center.runtime_name}'" in first.service_worker_source - shell_block, optional_block = first.service_worker_source.split( "const OPTIONAL_FEATURES = [", 1 ) @@ -127,14 +126,29 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path): assert security_changed.feature_bundles["security-center"].runtime_name != security_center.runtime_name +def test_my_work_has_a_small_blocking_bundle_before_optional_workspace_hydration(): + build = build_frontend(FRONTEND) + work_core = build.feature_bundles["work-core"] + today = build.feature_bundles["today-timer"] + + assert b"function buildMyWork" in work_core.runtime_bytes + assert b"function createProgressiveMyWork" in work_core.runtime_bytes + assert b"function buildMyWork" not in today.runtime_bytes + assert b"loadWorkspace({document,window})" in build.runtime_bytes + assert b"const workspaceLifecycle" not in build.runtime_bytes + assert b"const workspaceLifecycle" in today.runtime_bytes + assert SCRIPT_PRELOAD.findall(build.dashboard_html) == [work_core.runtime_name] + assert len(build.runtime_gzip_bytes) + len(work_core.runtime_gzip_bytes) <= 150 * 1024 + + def test_mandatory_workspace_fetch_is_preloaded_without_blocking_launch(): build = build_frontend(FRONTEND) - workspace = build.feature_bundles["today-timer"] + workspace = build.feature_bundles["work-core"] assert SCRIPT_PRELOAD.findall(build.dashboard_html) == [workspace.runtime_name] assert build.dashboard_html.count(f'') == 0 assert len(build.runtime_gzip_bytes) <= 100 * 1024 - assert len(workspace.runtime_gzip_bytes) <= 111 * 1024 + assert len(build.runtime_gzip_bytes) + len(workspace.runtime_gzip_bytes) <= 150 * 1024 shell_block = build.service_worker_source.split("const OPTIONAL_FEATURES = [", 1)[0] assert f"BASE + '{workspace.runtime_name}'" not in shell_block diff --git a/tests/test_progressive_my_work.py b/tests/test_progressive_my_work.py new file mode 100644 index 0000000..2d168d3 --- /dev/null +++ b/tests/test_progressive_my_work.py @@ -0,0 +1,38 @@ +import json +import subprocess +from pathlib import Path + + +MODULE = Path(__file__).parents[1] / "frontend" / "progressive-my-work.js" +MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js" + + +def test_progressive_my_work_renders_and_filters_assigned_work_before_full_workspace(): + harness = f""" +const fs=require('fs'); const vm=require('vm'); +const buttons=[ + {{dataset:{{workFilter:'all'}},attrs:{{}},addEventListener(n,cb){{this.cb=cb;}},setAttribute(n,v){{this.attrs[n]=v;}}}}, + {{dataset:{{workFilter:'issue'}},attrs:{{}},addEventListener(n,cb){{this.cb=cb;}},setAttribute(n,v){{this.attrs[n]=v;}}}}, +]; +const list={{innerHTML:''}}; const status={{textContent:''}}; +const document={{querySelector:s=>s==='#my-work-list'?list:s==='#my-work-status'?status:null,querySelectorAll:()=>buttons}}; +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 createProgressiveMyWork=context.module.exports; +const flow=createProgressiveMyWork({{document,fetchSnapshot:async()=>({{ + user:{{login:'timmy'}},issues:[{{number:7,title:'Fix mobile queue',repository:'stackchain/dashboard',assignees:['timmy'],url:'https://forge.example/issues/7'}}],pull_requests:[] +}})}}); +(async()=>{{ + await flow.start(); const rendered=list.innerHTML; + buttons[1].cb(); + console.log(JSON.stringify({{status:status.textContent,rendered,pressed:buttons.map(b=>b.attrs['aria-pressed'])}})); +}})().catch(e=>{{console.error(e);process.exit(1);}}); +""" + result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True) + state = json.loads(result.stdout) + assert state["status"] == "1 assigned work item ready." + assert "Fix mobile queue" in state["rendered"] + assert 'href="https://forge.example/issues/7"' in state["rendered"] + assert state["pressed"] == ["false", "true"] \ No newline at end of file diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index f0bcbc6..2b2049e 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -1408,6 +1408,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/offline-work.js", "/dashboard/static/offline-today.js", "/dashboard/static/my-work.js", + "/dashboard/static/progressive-my-work.js", "/dashboard/static/agenda-replan.js", "/dashboard/static/agenda-calendar.js", "/dashboard/static/protect-today.js", diff --git a/tests/test_workspace_bootstrap.py b/tests/test_workspace_bootstrap.py index b1ba717..a740b44 100644 --- a/tests/test_workspace_bootstrap.py +++ b/tests/test_workspace_bootstrap.py @@ -24,6 +24,7 @@ def test_workspace_bootstrap_loads_content_addressed_feature_before_startup(): const status={textContent:''}; const document={ querySelector(selector) { + if (selector === 'meta[name="stackchain-feature-work-core"]') return {content:'feature-work-core-123.js'}; if (selector === 'meta[name="stackchain-feature-today-timer"]') return {content:'feature-workspace-abc.js'}; if (selector === 'meta[name="stackchain-feature-planning"]') return {content:'feature-planning-def.js'}; if (selector === '#my-work-action-status') return status; @@ -37,6 +38,7 @@ console.log(JSON.stringify({requested,status:status.textContent})); """) assert result == { "requested": [ + "work-core:feature-work-core-123.js", "today-timer:feature-workspace-abc.js", "planning:feature-planning-def.js", ], @@ -44,6 +46,30 @@ console.log(JSON.stringify({requested,status:status.textContent})); } +def test_workspace_bootstrap_returns_after_work_core_while_optional_features_hydrate(): + result = run_bootstrap(""" +const document={querySelector(selector) { + const match=selector.match(/stackchain-feature-([^\"]+)/); + return match ? {content:'feature-' + match[1] + '.js'} : null; +}}; +const requested=[]; const releases={}; +const createLoader=()=>({load:name=>{ + requested.push(name); + if (name === 'work-core') return Promise.resolve(); + return new Promise(resolve=>{releases[name]=resolve;}); +}}); +const lifecycle=await loadWorkspace({document,createLoader}); +const returned=requested.slice(); +releases['today-timer'](); releases.planning(); +await lifecycle.optionalReady; +console.log(JSON.stringify({returned,settled:requested})); +""") + assert result == { + "returned": ["work-core", "today-timer", "planning"], + "settled": ["work-core", "today-timer", "planning"], + } + + def test_workspace_bootstrap_recovers_one_transient_failure_in_place(): result = run_bootstrap(""" const status={textContent:''}; const retry={hidden:true,disabled:false}; let attempts=0; @@ -72,7 +98,7 @@ const document={querySelector(selector) { return null; }}; const window={location:{reload(){reloads++;}},addEventListener(){},removeEventListener(){}}; -const createLoader=()=>({load:async()=>{attempts++; if (attempts < 5) throw new Error('offline');}}); +const createLoader=()=>({load:async()=>{attempts++; if (attempts < 3) throw new Error('offline');}}); const loading=loadWorkspace({document,window,createLoader,schedule:callback=>callback()}); await new Promise(resolve=>setImmediate(resolve)); const offered={hidden:retry.hidden,disabled:retry.disabled,status:status.textContent}; @@ -81,7 +107,7 @@ await Promise.all([first,second,loading]); console.log(JSON.stringify({attempts,reloads,offered,status:status.textContent,retryHidden:retry.hidden})); """) assert result == { - "attempts": 6, + "attempts": 5, "reloads": 0, "offered": { "hidden": False, @@ -116,7 +142,7 @@ const window={ addEventListener(name,callback) { listeners[name]=callback; }, removeEventListener(name,callback) { if (listeners[name] === callback) delete listeners[name]; }, }; -const createLoader=()=>({load:async()=>{attempts++; if (attempts < 5) throw new Error('offline');}}); +const createLoader=()=>({load:async()=>{attempts++; if (attempts < 3) throw new Error('offline');}}); const loading=loadWorkspace({document,window,createLoader,schedule:callback=>callback()}); await new Promise(resolve=>setImmediate(resolve)); const waiting=Boolean(listeners.online); @@ -125,7 +151,7 @@ await loading; console.log(JSON.stringify({attempts,waiting,reloads,status:status.textContent})); """) assert result == { - "attempts": 6, + "attempts": 5, "waiting": True, "reloads": 0, "status": "", -- 2.43.0