From 4b9aff466718ce3dd431242c5d37db22870e96ed Mon Sep 17 00:00:00 2001 From: zzal Date: Sun, 19 Jul 2026 20:30:57 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20host-abort=20gates=20=E2=80=94=20Dropdow?= =?UTF-8?q?n/Tabs=20rove=20on=20arch(wasm32),=20BrowserNavigator=20fails?= =?UTF-8?q?=20loud=20at=20construction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit canImport(JavaScriptKit) is true on host, and JavaScriptKit's host stub aborts with no message on the first JSObject.global access. Three consequences fixed: - Dropdown/Tabs keyboard-rove blocks claimed '(a no-op on host)' but compiled their JS crossing into host builds — any host keydown test died in a messageless SIGABRT. The crossing is now arch(wasm32)-keyed; the pure roving decision and Tabs' selection move stay host-side, now covered by a live harness suite (previously impossible to write). - BrowserNavigator's accessor fatalErrors ('host tests must inject a Navigator') could never print on host — the abort fired first. The named failure now happens at construction, where it still reaches the terminal. - TaskDiffTests held _swiflowDiagnosticOverride across an await, racing the LIFO restore against parallel suites (the lock guarding this is target-local to SwiflowQueryTests). The diagnostic fires synchronously, so the restore moves before the await. Whole-package swift test: 1906 green. SwiflowUIDemo + MiniRouter wasm builds verified locally. Co-Authored-By: Claude Fable 5 --- .../SwiflowRouter/Web/BrowserNavigator.swift | 15 ++++- Sources/SwiflowUI/Dropdown.swift | 8 ++- Sources/SwiflowUI/Tabs.swift | 7 ++- Tests/SwiflowTests/TaskDiffTests.swift | 6 +- Tests/SwiflowUITests/TabsTests.swift | 57 +++++++++++++++++++ 5 files changed, 85 insertions(+), 8 deletions(-) diff --git a/Sources/SwiflowRouter/Web/BrowserNavigator.swift b/Sources/SwiflowRouter/Web/BrowserNavigator.swift index 76505b49..cc78e082 100644 --- a/Sources/SwiflowRouter/Web/BrowserNavigator.swift +++ b/Sources/SwiflowRouter/Web/BrowserNavigator.swift @@ -29,7 +29,20 @@ package final class BrowserNavigator: Navigator { /// The event name registered alongside `listenerValue`. private var listenerEvent: String? - package init() {} + package init() { + // canImport(JavaScriptKit) is true on host, and the first + // JSObject.global access there aborts with NO message — the + // accessor fatalErrors below can never print on host. Fail at + // construction instead, where a diagnostic still reaches the + // terminal. + #if !arch(wasm32) + fatalError( + "Swiflow router: BrowserNavigator requires a browser environment. " + + "Host tests must inject a Navigator (RouterRoot's package " + + "init with a MockNavigator)." + ) + #endif + } private var window: JSObject { guard let w = JSObject.global.window.object else { diff --git a/Sources/SwiflowUI/Dropdown.swift b/Sources/SwiflowUI/Dropdown.swift index 3dbb6b4e..36e2d695 100644 --- a/Sources/SwiflowUI/Dropdown.swift +++ b/Sources/SwiflowUI/Dropdown.swift @@ -215,11 +215,13 @@ final class DropdownMenu { } /// The imperative half: apply the roving decision through the DOM. - /// `#if canImport(JavaScriptKit)`-guarded (a no-op on host), mirroring - /// Autocomplete's focus-by-id. + /// The JS crossing is keyed on `arch(wasm32)`, NOT canImport — canImport + /// is true on host, where merely touching `JSObject.global` aborts with + /// no message. A host keydown still computes the decision; only the DOM + /// focus move is wasm-only. private static func rove(_ e: EventInfo, current: String, order: [String], menuID: String) { guard let action = roveTarget(key: e.key, current: current, order: order) else { return } - #if canImport(JavaScriptKit) + #if arch(wasm32) guard let doc = JSObject.global.document.object else { return } switch action { case .closeMenu: diff --git a/Sources/SwiflowUI/Tabs.swift b/Sources/SwiflowUI/Tabs.swift index f4f4b55b..55d188a0 100644 --- a/Sources/SwiflowUI/Tabs.swift +++ b/Sources/SwiflowUI/Tabs.swift @@ -200,12 +200,13 @@ final class TabsView { /// The imperative half: on a roving key, select the target tab (automatic /// activation — re-renders, moving `tabindex`/`hidden`), then move DOM focus onto - /// it. `#if canImport(JavaScriptKit)`-guarded (a no-op on host), mirroring - /// `DropdownMenu.rove`. + /// it. Selection stays outside the gate so host tests can drive the roving + /// semantics; the focus move is keyed on `arch(wasm32)`, NOT canImport — + /// canImport is true on host, where touching `JSObject.global` aborts. private func handleRove(_ e: EventInfo, current: Int) { guard let target = TabsView.tabRoveTarget(key: e.key, current: current, count: labels.count) else { return } selectIndex(target) - #if canImport(JavaScriptKit) + #if arch(wasm32) guard let doc = JSObject.global.document.object else { return } if let el = doc.getElementById?("\(idPrefix)-tab-\(target)").object { _ = el.focus?() } #endif diff --git a/Tests/SwiflowTests/TaskDiffTests.swift b/Tests/SwiflowTests/TaskDiffTests.swift index fc78dcc2..414bb011 100644 --- a/Tests/SwiflowTests/TaskDiffTests.swift +++ b/Tests/SwiflowTests/TaskDiffTests.swift @@ -99,7 +99,6 @@ struct TaskDiffTests { var captured: [String] = [] let prior = _swiflowDiagnosticOverride _swiflowDiagnosticOverride = { captured.append($0) } - defer { _swiflowDiagnosticOverride = prior } // New render declares two tasks where there was one — stable-slot violation. inScope { @@ -107,6 +106,11 @@ struct TaskDiffTests { new: [TaskBinding(dependency: AnyEquatableBox(1), body: {}), TaskBinding(dependency: nil, body: {})]) } + // The diagnostic fires synchronously inside reconcileTasks, so restore + // BEFORE the await: a process-global override held across a suspension + // lets parallel suites interleave installs and break the LIFO restore + // (the seam's synchronous-hold invariant — see Diagnostics.swift). + _swiflowDiagnosticOverride = prior await drain() #expect(captured.contains { $0.contains("`.task` count") }) // The grow path still ran past the diagnostic and started the extra slot. diff --git a/Tests/SwiflowUITests/TabsTests.swift b/Tests/SwiflowUITests/TabsTests.swift index 25394e66..e1f77f29 100644 --- a/Tests/SwiflowUITests/TabsTests.swift +++ b/Tests/SwiflowUITests/TabsTests.swift @@ -8,6 +8,7 @@ import Testing @testable import Swiflow // HandlerAmbient / HandlerRegistry / EventInfo for the click dispatch @testable import SwiflowUI +import SwiflowTesting // live-harness keyboard-roving suite @MainActor private func el(_ node: VNode?) -> ElementData? { if case .element(let data)? = node { return data } @@ -298,3 +299,59 @@ struct TabsTests { #expect(css.contains(".sw-tabs__tab[aria-selected=\"true\"] { border-bottom-color: transparent; }")) } } + +// MARK: - Live keyboard roving (harness-mounted) + +/// Hosts a real Tabs in the headless harness so keydown drives the FULL +/// path — handleRove's selection move runs on host (only the DOM focus() +/// crossing is arch(wasm32)-gated). Before that gate, a host keydown +/// aborted in JSObject.global with no message, which is why no such test +/// could exist. +@Component +private final class TabsRovingHost { + @State var selection: String = "one" + var body: VNode { + Tabs(selection: $selection) { + Tab("One", id: "one") { text("first") } + Tab("Two", id: "two") { text("second") } + Tab("Three", id: "three") { text("third") } + } + } +} + +@Suite("Tabs live keyboard roving") +@MainActor +struct TabsRovingTests { + + private func selectedLabel(_ h: TestHarness) -> String? { + h.findAll(role: "tab").first { $0.attributes["aria-selected"] == "true" }?.text + } + + @Test("ArrowRight/ArrowLeft/Home/End move the selection (automatic activation), wrapping") + func arrowKeysMoveSelection() { + let h = render(TabsRovingHost()) + #expect(selectedLabel(h) == "One") + + h.findAll(role: "tab")[0].press(key: "ArrowRight") + #expect(selectedLabel(h) == "Two") + + h.findAll(role: "tab")[1].press(key: "End") + #expect(selectedLabel(h) == "Three") + + h.findAll(role: "tab")[2].press(key: "ArrowRight") // wraps + #expect(selectedLabel(h) == "One") + + h.findAll(role: "tab")[0].press(key: "ArrowLeft") // wraps back + #expect(selectedLabel(h) == "Three") + + h.findAll(role: "tab")[2].press(key: "Home") + #expect(selectedLabel(h) == "One") + } + + @Test("a non-roving key leaves the selection alone") + func otherKeysIgnored() { + let h = render(TabsRovingHost()) + h.findAll(role: "tab")[0].press(key: "Enter") + #expect(selectedLabel(h) == "One") + } +}