From 820da7130d52a2220f448a29bf0541ee9072760c Mon Sep 17 00:00:00 2001 From: timmy Date: Tue, 11 Aug 2026 03:30:07 +0000 Subject: [PATCH] feat: lazy-load issue capture workflow (Closes #537) --- frontend/dashboard.js | 30 +++++++++++++-- frontend/feature-loader.js | 60 +++++++++++++++++++++++++++++ frontend/index.html | 1 + frontend/service-worker.js | 1 + src/frontend_bundle.py | 55 +++++++++++++++++++++------ src/views.py | 17 +++++++++ tests/test_feature_loader.py | 71 +++++++++++++++++++++++++++++++++++ tests/test_frontend_bundle.py | 40 +++++++++++++++++++- tests/test_my_work.py | 8 ++++ tests/test_service_worker.py | 1 + 10 files changed, 268 insertions(+), 16 deletions(-) create mode 100644 frontend/feature-loader.js create mode 100644 tests/test_feature_loader.py diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 6f68a83..abdc41c 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -366,7 +366,7 @@ loadLabels: item => issueController.loadLabels(item), loadMilestones: item => issueController.loadMilestones(item), }); - const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage }); + let issueCapture = null; const unfiledCaptures = createUnfiledCaptures({ storage: localStorage, getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim(), @@ -416,8 +416,28 @@ text: shareParams.get('text') || '', url: shareParams.get('url') || '', }; - let sharedLaunchState = Object.values(sharedLaunch).some(Boolean) ? - issueCapture.stageSharedContent(sharedLaunch) : null; + const issueCaptureFeatures = createFeatureLoader({ + document, + urls: { + 'issue-capture': document.querySelector('meta[name="stackchain-feature-issue-capture"]')?.content || '', + }, + }); + let sharedLaunchState = null; + let sharedLaunchHandled = false; + async function ensureIssueCapture() { + return await issueCaptureFeatures.run('issue-capture', { + trigger: qs('#new-issue'), status: qs('#my-work-action-status'), + }, () => { + if (!issueCapture) { + issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage }); + } + if (!sharedLaunchHandled && Object.values(sharedLaunch).some(Boolean)) { + sharedLaunchState = issueCapture.stageSharedContent(sharedLaunch); + sharedLaunchHandled = true; + } + }); + } + if (Object.values(sharedLaunch).some(Boolean)) await ensureIssueCapture(); const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage }); const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin }); outboxCoordinator.subscribe(() => refreshMyWorkView()); @@ -2759,6 +2779,7 @@ } function saveIssueCaptureDraft() { + if (!issueCapture) return; issueCapture.saveDraft({ repository: qs('#create-issue-repository').value, title: qs('#create-issue-title').value, @@ -2955,7 +2976,8 @@ sharedLaunchState = null; } - function openCreateIssueSheet(navigate = true) { + async function openCreateIssueSheet(navigate = true) { + if (!issueCapture && !await ensureIssueCapture()) return; if (navigate) { taskOverlayHistory.open('new'); return; diff --git a/frontend/feature-loader.js b/frontend/feature-loader.js new file mode 100644 index 0000000..4fa4beb --- /dev/null +++ b/frontend/feature-loader.js @@ -0,0 +1,60 @@ +function createFeatureLoader({ document, urls, timeoutMs = 10000 }) { + const pending = new Map(); + const loaded = new Set(); + + function load(name) { + if (loaded.has(name)) return Promise.resolve(); + if (pending.has(name)) return pending.get(name); + const url = urls[name]; + if (!url) return Promise.reject(new Error('Unknown feature: ' + name)); + + const request = new Promise((resolve, reject) => { + const script = document.createElement('script'); + let timeout; + const fail = () => { + clearTimeout(timeout); + script.remove(); + pending.delete(name); + reject(new Error('Could not load ' + name.replace(/-/g, ' ') + '.')); + }; + script.src = url; + script.async = true; + script.onload = () => { + clearTimeout(timeout); + pending.delete(name); + loaded.add(name); + resolve(); + }; + script.onerror = fail; + timeout = setTimeout(fail, timeoutMs); + document.head.appendChild(script); + }); + pending.set(name, request); + return request; + } + + async function run(name, elements, callback) { + const trigger = elements?.trigger; + const status = elements?.status; + if (trigger) trigger.disabled = true; + if (status) status.textContent = 'Loading ' + name.replace(/-/g, ' ') + '…'; + try { + await load(name); + callback(); + if (status) status.textContent = ''; + return true; + } catch (_error) { + if (status) { + const label = name === 'issue-capture' ? 'Issue capture' : name.replace(/-/g, ' '); + status.textContent = label + ' could not load. Tap New issue to retry.'; + } + return false; + } finally { + if (trigger) trigger.disabled = false; + } + } + + return { load, run, ready: name => loaded.has(name) }; +} + +if (typeof module !== 'undefined' && module.exports) module.exports = createFeatureLoader; \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index 7ea3752..5a9b399 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -718,6 +718,7 @@ + diff --git a/frontend/service-worker.js b/frontend/service-worker.js index e0ef9e7..4c84dee 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -12,6 +12,7 @@ const SHELL = [ BASE + 'static/icons/stackchain-192.png', BASE + 'static/icons/stackchain-512.png', BASE + 'static/session.js', + BASE + 'static/feature-loader.js', BASE + 'static/markdown.js', BASE + 'static/commands.js', BASE + 'static/search-preview.js', diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index 143cd17..1a9815c 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -13,11 +13,20 @@ import rjsmin SCRIPT_TAG = re.compile(r'^$', re.MULTILINE) WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js" +FEATURE_SOURCES = {"issue-capture": ("static/create-issue-sheet.js",)} CACHE_DECLARATION = re.compile( r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';" ) +@dataclass(frozen=True) +class RuntimeBundle: + runtime_bytes: bytes + runtime_gzip_bytes: bytes + runtime_digest: str + runtime_name: str + + @dataclass(frozen=True) class FrontendBuild: dashboard_html: str @@ -27,6 +36,7 @@ class FrontendBuild: runtime_name: str shell_digest: str page_sources: tuple[str, ...] + feature_bundles: dict[str, RuntimeBundle] service_worker_source: str @@ -41,6 +51,17 @@ def _bundle(frontend_dir: Path, sources: tuple[str, ...]) -> bytes: return minified + f';"source-sha256:{revision}";'.encode() +def _runtime(frontend_dir: Path, sources: tuple[str, ...], prefix: str) -> RuntimeBundle: + content = _bundle(frontend_dir, sources) + digest = hashlib.sha256(content).hexdigest()[:16] + return RuntimeBundle( + runtime_bytes=content, + runtime_gzip_bytes=gzip.compress(content, compresslevel=6, mtime=0), + runtime_digest=digest, + runtime_name=f"{prefix}-{digest}.js", + ) + + def build_frontend(frontend_dir: Path) -> FrontendBuild: """Return one internally consistent HTML, runtime, and offline-worker build.""" source_html = (frontend_dir / "index.html").read_text() @@ -48,13 +69,21 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild: if not sources: raise ValueError("dashboard entry document has no page scripts") - runtime = _bundle(frontend_dir, sources) - runtime_gzip = gzip.compress(runtime, compresslevel=6, mtime=0) - digest = hashlib.sha256(runtime).hexdigest()[:16] - runtime_name = f"runtime-{digest}.js" + feature_source_names = {source for group in FEATURE_SOURCES.values() for source in group} + core_sources = tuple(source for source in sources if source not in feature_source_names) + core = _runtime(frontend_dir, core_sources, "runtime") + feature_bundles = { + name: _runtime(frontend_dir, feature_sources, f"feature-{name}") + for name, feature_sources in FEATURE_SOURCES.items() + } dashboard_html = SCRIPT_TAG.sub("", source_html) + feature_metadata = "\n".join( + f'' + for name, bundle in feature_bundles.items() + ) + dashboard_html = dashboard_html.replace("", feature_metadata + "\n") dashboard_html = dashboard_html.replace( - "", f'\n' + "", f'\n' ) worker = (frontend_dir / "service-worker.js").read_text() @@ -63,7 +92,9 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild: worker = worker.replace(f" BASE + '{source}',\n", "") worker = worker.replace( " BASE + 'static/dashboard.css',\n", - f" BASE + 'static/dashboard.css',\n BASE + '{runtime_name}',\n", + " BASE + 'static/dashboard.css',\n" + + f" BASE + '{core.runtime_name}',\n" + + "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in feature_bundles.values()), ) worker = CACHE_DECLARATION.sub( "const CACHE = 'stackchain-dashboard-shell-BUILD';", worker, count=1 @@ -72,7 +103,8 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild: shell_hasher = hashlib.sha256() shell_inputs = ( ("dashboard.html", dashboard_html.encode()), - (runtime_name, runtime), + (core.runtime_name, core.runtime_bytes), + *((bundle.runtime_name, bundle.runtime_bytes) for bundle in feature_bundles.values()), ("static/dashboard.css", (frontend_dir / "dashboard.css").read_bytes()), ("manifest.webmanifest", (frontend_dir / "manifest.webmanifest").read_bytes()), ( @@ -105,11 +137,12 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild: return FrontendBuild( dashboard_html=dashboard_html, - runtime_bytes=runtime, - runtime_gzip_bytes=runtime_gzip, - runtime_digest=digest, - runtime_name=runtime_name, + runtime_bytes=core.runtime_bytes, + runtime_gzip_bytes=core.runtime_gzip_bytes, + runtime_digest=core.runtime_digest, + runtime_name=core.runtime_name, shell_digest=shell_digest, page_sources=sources, + feature_bundles=feature_bundles, service_worker_source=worker, ) diff --git a/src/views.py b/src/views.py index 34dab13..3a62b00 100644 --- a/src/views.py +++ b/src/views.py @@ -69,3 +69,20 @@ async def runtime_bundle(digest: str, request: Request) -> Response: **({"Content-Encoding": "gzip"} if accepts_gzip else {}), }, ) + + +@router.get("/feature-{name}-{digest}.js") +async def feature_bundle(name: str, digest: str, request: Request) -> Response: + bundle = FRONTEND_BUILD.feature_bundles.get(name) + if bundle is None or digest != bundle.runtime_digest: + raise HTTPException(status_code=404, detail="Feature revision not found") + accepts_gzip = _quality(request.headers.get("Accept-Encoding", ""), "gzip") > 0 + return Response( + bundle.runtime_gzip_bytes if accepts_gzip else bundle.runtime_bytes, + media_type="application/javascript", + headers={ + "Cache-Control": "public, max-age=31536000, immutable", + "Vary": "Accept-Encoding", + **({"Content-Encoding": "gzip"} if accepts_gzip else {}), + }, + ) diff --git a/tests/test_feature_loader.py b/tests/test_feature_loader.py new file mode 100644 index 0000000..bcf8857 --- /dev/null +++ b/tests/test_feature_loader.py @@ -0,0 +1,71 @@ +import json +import subprocess +from pathlib import Path + + +LOADER = Path(__file__).parents[1] / "frontend" / "feature-loader.js" + + +def run_loader(scenario: str) -> dict: + harness = f""" +const fs=require('fs'); const vm=require('vm'); +const state={{appends:0, removed:0}}; +const document={{ + head:{{appendChild(node){{ state.appends++; state.node=node; }} }}, + createElement(){{ return {{remove(){{state.removed++;}}}}; }}, +}}; +const context={{document, setTimeout, clearTimeout, console}}; +context.globalThis=context; +vm.createContext(context); +vm.runInContext(fs.readFileSync({json.dumps(str(LOADER))},'utf8'),context); +const createFeatureLoader=context.createFeatureLoader; +(async()=>{{ {scenario} }})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + completed = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True) + return json.loads(completed.stdout) + + +def test_concurrent_feature_requests_share_one_script(): + result = run_loader(""" +const loader=createFeatureLoader({document, urls:{'issue-capture':'feature-a.js'}, timeoutMs:100}); +const first=loader.load('issue-capture'); const second=loader.load('issue-capture'); +state.node.onload(); await Promise.all([first,second]); +console.log(JSON.stringify({appends:state.appends,same:first===second,ready:loader.ready('issue-capture')})); +""") + assert result == {"appends": 1, "same": True, "ready": True} + + +def test_failed_feature_request_can_retry_without_reload(): + result = run_loader(""" +const loader=createFeatureLoader({document, urls:{'issue-capture':'feature-a.js'}, timeoutMs:100}); +const failed=loader.load('issue-capture').catch(error=>error.message); state.node.onerror(); +const message=await failed; const retry=loader.load('issue-capture'); state.node.onload(); await retry; +console.log(JSON.stringify({appends:state.appends,removed:state.removed,message,ready:loader.ready('issue-capture')})); +""") + assert result == {"appends": 2, "removed": 1, "message": "Could not load issue capture.", "ready": True} + + +def test_feature_gate_exposes_loading_failure_and_success_to_mobile_controls(): + result = run_loader(""" +const trigger={disabled:false}; const status={textContent:''}; let opened=0; +const loader=createFeatureLoader({document, urls:{'issue-capture':'feature-a.js'}, timeoutMs:100}); +const failed=loader.run('issue-capture',{trigger,status},()=>{opened++;}); +const loading={disabled:trigger.disabled,status:status.textContent}; state.node.onerror(); +const first=await failed; +const failedState={disabled:trigger.disabled,status:status.textContent,opened}; +const retry=loader.run('issue-capture',{trigger,status},()=>{opened++;}); state.node.onload(); +const second=await retry; +console.log(JSON.stringify({loading,failedState,first,second,opened,finalStatus:status.textContent})); +""") + assert result == { + "loading": {"disabled": True, "status": "Loading issue capture…"}, + "failedState": { + "disabled": False, + "status": "Issue capture could not load. Tap New issue to retry.", + "opened": 0, + }, + "first": False, + "second": True, + "opened": 1, + "finalStatus": "", + } \ No newline at end of file diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py index 7388c2f..91aaee7 100644 --- a/tests/test_frontend_bundle.py +++ b/tests/test_frontend_bundle.py @@ -9,7 +9,7 @@ from starlette.requests import Request from src import main from src.frontend_bundle import build_frontend -from src.views import dashboard, runtime_bundle, service_worker +from src.views import dashboard, feature_bundle, runtime_bundle, service_worker FRONTEND = Path(__file__).resolve().parents[1] / "frontend" @@ -42,6 +42,44 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path): assert changed.runtime_bytes != first.runtime_bytes +def test_issue_capture_is_a_stable_lazy_feature_chunk(tmp_path): + first = build_frontend(FRONTEND) + + assert set(first.feature_bundles) == {"issue-capture"} + capture = first.feature_bundles["issue-capture"] + assert b"function createIssueCapture" not in first.runtime_bytes + assert b"function createIssueCapture" in capture.runtime_bytes + assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html + assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source + + changed_frontend = tmp_path / "frontend" + shutil.copytree(FRONTEND, changed_frontend) + capture_source = changed_frontend / "create-issue-sheet.js" + capture_source.write_text(capture_source.read_text() + "\n// feature-only change\n") + + changed = build_frontend(changed_frontend) + assert changed.runtime_name == first.runtime_name + assert changed.feature_bundles["issue-capture"].runtime_name != capture.runtime_name + + +@pytest.mark.anyio +async def test_feature_chunk_is_immutable_and_rejects_unknown_revision(): + build = build_frontend(FRONTEND) + capture = build.feature_bundles["issue-capture"] + + request = Request( + {"type": "http", "method": "GET", "path": f"/{capture.runtime_name}", + "headers": [(b"accept-encoding", b"identity")]} + ) + response = await feature_bundle("issue-capture", capture.runtime_digest, request) + + assert response.body == capture.runtime_bytes + assert response.headers["cache-control"] == "public, max-age=31536000, immutable" + with pytest.raises(Exception) as rejected: + await feature_bundle("issue-capture", "missing", request) + assert rejected.value.status_code == 404 + + def test_css_only_release_gets_a_new_shell_generation(tmp_path): first = build_frontend(FRONTEND) changed_frontend = tmp_path / "frontend" diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 52321e1..a4b2c3f 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -4337,6 +4337,14 @@ async def test_mobile_my_work_captures_new_issue_in_accessible_draft_safe_sheet( assert '.create-issue-label-option' in html and 'min-height:44px' in html +def test_new_issue_capture_waits_for_retryable_feature_before_opening(): + dashboard_source = (Path(__file__).parents[1] / "frontend" / "dashboard.js").read_text() + + assert "await issueCaptureFeatures.run('issue-capture'" in dashboard_source + assert "if (!issueCapture)" in dashboard_source + assert "async function openCreateIssueSheet" in dashboard_source + + @pytest.mark.anyio async def test_mobile_issue_capture_warns_before_queuing_a_possible_duplicate(): html = await dashboard() diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 36613ec..0a257ed 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -419,6 +419,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/icons/stackchain-192.png", "/dashboard/static/icons/stackchain-512.png", "/dashboard/static/session.js", + "/dashboard/static/feature-loader.js", "/dashboard/static/markdown.js", "/dashboard/static/commands.js", "/dashboard/static/search-preview.js", -- 2.43.0