From 82edf434174fc53c9d429373e6a4df8503e179d1 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 12 Nov 2026 07:53:29 +0000 Subject: [PATCH] fix(orchestrator): a view transition skipped by the next switch stays quiet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apply() starts a view transition on every hash change; a quick second switch skips the running one, whose ready promise then rejected with an unhandled AbortError, which the presence e2e counted as a page error in Chromium and Firefox. The skip is expected, so its AbortError is caught; every other ready failure is rethrown and still surfaces. 🎯 Fix: 92 % 💥 Break: 2 % 💸 Cost: 100 % ⏱️ Time: 100 % 🛡️ Security: 100 % 🏆 Score: 90 🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 --- orchestrator/templates/page-screens.js | 6 +++- tests/test_hub_nav.py | 39 +++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/orchestrator/templates/page-screens.js b/orchestrator/templates/page-screens.js index e7566ca..4de18a5 100644 --- a/orchestrator/templates/page-screens.js +++ b/orchestrator/templates/page-screens.js @@ -37,7 +37,11 @@ ui.views[route.screen] = route.view; } if (document.startViewTransition && drawn.screen && !calm.matches) { - document.startViewTransition(() => render(true)); + document.startViewTransition(() => render(true)).ready.catch((error) => { + if (error.name !== "AbortError") { + throw error; + } + }); } else { render(true); } diff --git a/tests/test_hub_nav.py b/tests/test_hub_nav.py index 9987d76..74a4a4a 100644 --- a/tests/test_hub_nav.py +++ b/tests/test_hub_nav.py @@ -4,7 +4,38 @@ import json import subprocess import unittest -from hub_support import NAV +from hub_support import HUB, NAV + +SCREENS = HUB / "templates" / "page-screens.js" +TRANSITION_PROBE = """ +const fs = require("fs"); +const vm = require("vm"); +const code = fs.readFileSync(process.argv[1], "utf8"); +const unhandled = []; +process.on("unhandledRejection", (error) => unhandled.push(error.name)); +const settle = () => new Promise((resolve) => setImmediate(resolve)); +function switched(name) { + const error = new Error(`transition ${name}`); + error.name = name; + const started = []; + vm.runInNewContext(`${code}\\napply();`, { + Nav: {TABS: [], route: () => ({screen: "agents", view: "chord"})}, ui: {views: {}}, drawn: {screen: true}, calm: {matches: false}, h: () => null, + window: {location: {hash: "#agents/chord"}, history: {replaceState: () => null}}, + document: {getElementById: () => ({append: () => null}), startViewTransition: (update) => { + started.push(typeof update); + return {ready: Promise.reject(error), finished: Promise.resolve(), updateCallbackDone: Promise.resolve()}; + }}, + }); + return started; +} +(async () => { + const started = [...switched("AbortError"), ...switched("InvalidStateError")]; + for (let round = 0; round < 5; round += 1) { + await settle(); + } + console.log(JSON.stringify({started, unhandled})); +})().catch((error) => console.log(JSON.stringify({crash: String(error)}))); +""" NAV_PROBE = """ const N = require(process.argv[1]); @@ -90,5 +121,11 @@ class TestNavModule(unittest.TestCase): self.assertEqual(self.data["tabOf"], ["stream", "stream", "stream", "agents", "artifacts", "tasks"]) +class TestScreenTransition(unittest.TestCase): + def test_a_view_transition_skipped_by_the_next_switch_stays_quiet_and_other_failures_still_surface(self): + done = subprocess.run(["node", "-e", TRANSITION_PROBE, str(SCREENS)], capture_output=True, text=True, timeout=60, check=True) + self.assertEqual(json.loads(done.stdout), {"started": ["function", "function"], "unhandled": ["InvalidStateError"]}) + + if __name__ == "__main__": unittest.main()