fix(ui): bind the shared header controls on explicit init, not at module import
header.js's control bindings (sign-out, the mobile hamburger, the SINGLE New chat button) ran at module import. The Containerfile stage-1 build inlines header.js into every bundle that imports it (the shell's app.js, token-gate.js and the router's lazy views), so the shell page registered the #nav-toggle click handler twice, and two toggle handlers cancel each other — one tap = open + close = the mobile menu dead in the deployed image only. The dev tree's single ESM instance (and every test that runs against it) never showed it; a lazy view load adding a THIRD copy made the menu work again, which is why the failure looked state-dependent (chat cold boot dead, /sources.html alive). - header.js: the three bindings move into an exported bindSharedHeaderControls(), guarded by a marker on <body> (NOT module state — every bundle copy has its own function instance), so later bundle copies and repeated inits (the token gate's mid-page header re-boot) are no-ops; header.js is now side-effect-free at top level, which also lets esbuild tree-shake the dead copies out of the bundles that do not need them (the token-gate bundle no longer carries the binding code at all) - app.js / login.js / shared.js / document.js: call bindSharedHeaderControls() once at module top — import-time parity, unconditional (no async boot path to miss); doc-edit.js ships no header controls and calls nothing - unit: tests/unit/test_header_bindings_once_per_document.py pins the contract — the init export, the document-level idempotency marker, all three bindings inside the init, NO top-level addEventListener remaining, and exactly one module-top call in each header-carrying page script; stale import-time docstrings in the legacy header pins updated to the new contract Verified: full unit + integration suite (1746 passed), the hamburger / pinned-composer / smoke E2E stories green in isolation, ruff + pyright clean. Containerfile-equivalent esbuild 0.25.5 rebuild probed in Chromium: exactly ONE #nav-toggle click listener on chat cold boot, /sources.html and login.html, and a touch tap opens the menu in all three states (pre-fix production: two listeners on cold boot = dead, three on sources = alive).
This commit is contained in:
@@ -148,8 +148,11 @@ def test_clear_chat_storage_removes_the_phase14_key_silently() -> None:
|
||||
|
||||
def test_sign_out_binding_lives_in_the_shared_module() -> None:
|
||||
"""The sign-out click binding (disable → POST /api/logout → reload)
|
||||
is owned by header.js at module import — exactly one implementation
|
||||
for every page that loads it. It binds to ALL .sign-out-btn
|
||||
is owned by header.js — bound once per document via the explicit
|
||||
bindSharedHeaderControls() init (NOT a module-import side effect:
|
||||
the Containerfile inlines header.js into every bundle that imports
|
||||
it, and import-time binding ran once per copy). It binds to ALL
|
||||
.sign-out-btn
|
||||
elements (the bar copy for desktop + the mobile dropdown copy for
|
||||
≤640px, phase 46), so both copies log out."""
|
||||
js = _text(HEADER_JS)
|
||||
@@ -373,10 +376,11 @@ def test_header_module_loads_before_the_page_script() -> None:
|
||||
header.js with a direct <script> tag anymore. Each page script
|
||||
imports it relatively (`from "./header.js"`) — a hoisted import that
|
||||
the browser evaluates BEFORE the page script body runs, and that the
|
||||
image bundler inlines into the page bundle. The sign-out binding and
|
||||
the whoami cache therefore exist when the page script boots, and
|
||||
header.js can never be evaluated twice on a page (a tag + import pair
|
||||
would double-bind the sign-out listener)."""
|
||||
image bundler inlines into the page bundle. The whoami cache
|
||||
therefore exists when the page script boots, and header.js can
|
||||
never be evaluated twice on a page (the single-evaluation design is
|
||||
the contract; a tag + import pair is now additionally harmless —
|
||||
the binding init is idempotent via the body marker)."""
|
||||
cases = [
|
||||
(INDEX_HTML, "app.js"),
|
||||
(DOCUMENT_HTML, "document.js"),
|
||||
@@ -490,8 +494,9 @@ def test_login_js_uses_the_shared_fetch_is_admin() -> None:
|
||||
|
||||
def test_new_chat_binding_is_single_and_module_owned() -> None:
|
||||
"""Phase 34 task 02 + owner rework (2026-08-28): header.js owns the
|
||||
SINGLE #new-chat-btn binding (module import, like the sign-out
|
||||
binding). The button now lives ONLY on the chat page (inside
|
||||
SINGLE #new-chat-btn binding (explicit init via
|
||||
bindSharedHeaderControls, like the sign-out binding). The button
|
||||
now lives ONLY on the chat page (inside
|
||||
.chat-shell, above #messages — moved from the navbar at owner
|
||||
request), so the click ALWAYS dispatches window "bor:new-chat" and
|
||||
app.js acts through its own in-flight-turn guard + list reset: no
|
||||
|
||||
Reference in New Issue
Block a user