Merge pull request 'Lazy-load the mobile issue capture workflow' (#538) from timmy/537-lazy-issue-capture into main
This commit is contained in:
commit
fb34b5ffda
|
|
@ -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;
|
||||
|
|
|
|||
60
frontend/feature-loader.js
Normal file
60
frontend/feature-loader.js
Normal file
|
|
@ -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;
|
||||
|
|
@ -718,6 +718,7 @@
|
|||
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
|
||||
|
||||
<script src="static/session.js"></script>
|
||||
<script src="static/feature-loader.js"></script>
|
||||
<script src="static/markdown.js"></script>
|
||||
<script src="static/commands.js"></script>
|
||||
<script src="static/search-preview.js"></script>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -13,11 +13,20 @@ import rjsmin
|
|||
|
||||
SCRIPT_TAG = re.compile(r'^<script src="(static/[^"?]+\.js)"></script>$', 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'<meta name="stackchain-feature-{name}" content="{bundle.runtime_name}">'
|
||||
for name, bundle in feature_bundles.items()
|
||||
)
|
||||
dashboard_html = dashboard_html.replace("</head>", feature_metadata + "\n</head>")
|
||||
dashboard_html = dashboard_html.replace(
|
||||
"</body>", f'<script src="{runtime_name}"></script>\n</body>'
|
||||
"</body>", f'<script src="{core.runtime_name}"></script>\n</body>'
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
|
|
|
|||
17
src/views.py
17
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 {}),
|
||||
},
|
||||
)
|
||||
|
|
|
|||
71
tests/test_feature_loader.py
Normal file
71
tests/test_feature_loader.py
Normal file
|
|
@ -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": "",
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user