From 8e4c2c139efccdc5c308d433aa72b1de65997d8d Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 28 Feb 2026 23:09:15 +0000 Subject: [PATCH] Fix duplicate menu rows on HTMX navigation between depth levels When navigating from a deeper page (e.g. day) to a shallower one (e.g. calendar) via HTMX, orphaned header rows from the deeper page persisted in the DOM because OOB swaps only replaced specific child divs, not siblings. Fix by sending empty OOB swaps to clear all header row IDs not present at the current depth. Applied to events (calendars/calendar/day/entry/admin/slots) and market (market_home/browse/product/admin). Also restore app_label in root header. Co-Authored-By: Claude Opus 4.6 --- events/sexp/sexp_components.py | 60 ++++++++++++++++++++++++++++++++++ market/sexp/sexp_components.py | 35 ++++++++++++++++++++ shared/sexp/helpers.py | 1 + 3 files changed, 96 insertions(+) diff --git a/events/sexp/sexp_components.py b/events/sexp/sexp_components.py index b56bff1..a784de2 100644 --- a/events/sexp/sexp_components.py +++ b/events/sexp/sexp_components.py @@ -36,6 +36,30 @@ _oob_header_html = oob_header_html # Post header helpers — thin wrapper over shared post_header_html # --------------------------------------------------------------------------- +def _clear_oob(*ids: str) -> str: + """Generate OOB swaps to remove orphaned header rows/children.""" + return "".join(f'
' for i in ids) + + +# All possible header row/child IDs at each depth (deepest first) +_EVENTS_DEEP_IDS = [ + "entry-admin-row", "entry-admin-header-child", + "entry-row", "entry-header-child", + "day-admin-row", "day-admin-header-child", + "day-row", "day-header-child", + "calendar-admin-row", "calendar-admin-header-child", + "calendar-row", "calendar-header-child", + "calendars-row", "calendars-header-child", + "post-admin-row", "post-admin-header-child", +] + + +def _clear_deeper_oob(*keep_ids: str) -> str: + """Clear all events header rows/children NOT in keep_ids.""" + to_clear = [i for i in _EVENTS_DEEP_IDS if i not in keep_ids] + return _clear_oob(*to_clear) + + async def _ensure_container_nav(ctx: dict) -> dict: """Fetch container_nav_html if not already present (for post header row).""" if ctx.get("container_nav_html"): @@ -1257,6 +1281,7 @@ async def render_page_summary_oob(ctx: dict, entries, has_more, pending_tickets, ) oobs = _post_header_html(ctx, oob=True) + oobs += _clear_deeper_oob("post-row", "post-header-child") return oob_page(ctx, oobs_html=oobs, content_html=content) @@ -1299,6 +1324,8 @@ async def render_calendars_oob(ctx: dict) -> str: ctx = await _ensure_container_nav(ctx) slug = (ctx.get("post") or {}).get("slug", "") oobs = post_admin_header_html(ctx, slug, oob=True, selected="calendars") + oobs += _clear_deeper_oob("post-row", "post-header-child", + "post-admin-row", "post-admin-header-child") return oob_page(ctx, oobs_html=oobs, content_html=content) @@ -1321,6 +1348,8 @@ async def render_calendar_oob(ctx: dict) -> str: oobs = _post_header_html(ctx, oob=True) oobs += _oob_header_html("post-header-child", "calendar-header-child", _calendar_header_html(ctx)) + oobs += _clear_deeper_oob("post-row", "post-header-child", + "calendar-row", "calendar-header-child") return oob_page(ctx, oobs_html=oobs, content_html=content) @@ -1344,6 +1373,9 @@ async def render_day_oob(ctx: dict) -> str: oobs = _calendar_header_html(ctx, oob=True) oobs += _oob_header_html("calendar-header-child", "day-header-child", _day_header_html(ctx)) + oobs += _clear_deeper_oob("post-row", "post-header-child", + "calendar-row", "calendar-header-child", + "day-row", "day-header-child") return oob_page(ctx, oobs_html=oobs, content_html=content) @@ -1374,6 +1406,11 @@ async def render_day_admin_oob(ctx: dict) -> str: + _calendar_header_html(ctx, oob=True)) oobs += _oob_header_html("day-header-child", "day-admin-header-child", _day_admin_header_html(ctx)) + oobs += _clear_deeper_oob("post-row", "post-header-child", + "post-admin-row", "post-admin-header-child", + "calendar-row", "calendar-header-child", + "day-row", "day-header-child", + "day-admin-row", "day-admin-header-child") return oob_page(ctx, oobs_html=oobs, content_html=content) @@ -1410,6 +1447,10 @@ async def render_calendar_admin_oob(ctx: dict) -> str: + _calendar_header_html(ctx, oob=True)) oobs += _oob_header_html("calendar-header-child", "calendar-admin-header-child", _calendar_admin_header_html(ctx)) + oobs += _clear_deeper_oob("post-row", "post-header-child", + "post-admin-row", "post-admin-header-child", + "calendar-row", "calendar-header-child", + "calendar-admin-row", "calendar-admin-header-child") return oob_page(ctx, oobs_html=oobs, content_html=content) @@ -1442,6 +1483,10 @@ async def render_slots_oob(ctx: dict) -> str: slug = (ctx.get("post") or {}).get("slug", "") oobs = (post_admin_header_html(ctx, slug, oob=True, selected="calendars") + _calendar_admin_header_html(ctx, oob=True)) + oobs += _clear_deeper_oob("post-row", "post-header-child", + "post-admin-row", "post-admin-header-child", + "calendar-row", "calendar-header-child", + "calendar-admin-row", "calendar-admin-header-child") return oob_page(ctx, oobs_html=oobs, content_html=content) @@ -1896,6 +1941,10 @@ async def render_entry_oob(ctx: dict) -> str: oobs = _day_header_html(ctx, oob=True) oobs += _oob_header_html("day-header-child", "entry-header-child", _entry_header_html(ctx)) + oobs += _clear_deeper_oob("post-row", "post-header-child", + "calendar-row", "calendar-header-child", + "day-row", "day-header-child", + "entry-row", "entry-header-child") nav_html = _entry_nav_html(ctx) return oob_page(ctx, oobs_html=oobs, content_html=content, menu_html=nav_html) @@ -2915,6 +2964,12 @@ async def render_entry_admin_oob(ctx: dict) -> str: + _entry_header_html(ctx, oob=True)) oobs += _oob_header_html("entry-header-child", "entry-admin-header-child", _entry_admin_header_html(ctx)) + oobs += _clear_deeper_oob("post-row", "post-header-child", + "post-admin-row", "post-admin-header-child", + "calendar-row", "calendar-header-child", + "day-row", "day-header-child", + "entry-row", "entry-header-child", + "entry-admin-row", "entry-admin-header-child") nav_html = render("events-admin-placeholder-nav") return oob_page(ctx, oobs_html=oobs, content_html=content, menu_html=nav_html) @@ -2983,6 +3038,11 @@ async def render_slot_oob(ctx: dict) -> str: + _calendar_admin_header_html(ctx, oob=True)) oobs += _oob_header_html("calendar-admin-header-child", "slot-header-child", _slot_header_html(ctx)) + oobs += _clear_deeper_oob("post-row", "post-header-child", + "post-admin-row", "post-admin-header-child", + "calendar-row", "calendar-header-child", + "calendar-admin-row", "calendar-admin-header-child", + "slot-row", "slot-header-child") return oob_page(ctx, oobs_html=oobs, content_html=content) diff --git a/market/sexp/sexp_components.py b/market/sexp/sexp_components.py index ae82527..9a39326 100644 --- a/market/sexp/sexp_components.py +++ b/market/sexp/sexp_components.py @@ -25,6 +25,25 @@ from shared.sexp.helpers import ( load_service_components(os.path.dirname(os.path.dirname(__file__))) +# --------------------------------------------------------------------------- +# OOB orphan cleanup +# --------------------------------------------------------------------------- + +_MARKET_DEEP_IDS = [ + "product-admin-row", "product-admin-header-child", + "product-row", "product-header-child", + "market-admin-row", "market-admin-header-child", + "market-row", "market-header-child", + "post-admin-row", "post-admin-header-child", +] + + +def _clear_deeper_oob(*keep_ids: str) -> str: + """Clear all market header rows/children NOT in keep_ids.""" + to_clear = [i for i in _MARKET_DEEP_IDS if i not in keep_ids] + return "".join(f'
' for i in to_clear) + + # --------------------------------------------------------------------------- # Price helpers # --------------------------------------------------------------------------- @@ -1285,6 +1304,8 @@ async def render_market_home_oob(ctx: dict) -> str: oobs = _oob_header_html("post-header-child", "market-header-child", _market_header_html(ctx)) oobs += _post_header_html(ctx, oob=True) + oobs += _clear_deeper_oob("post-row", "post-header-child", + "market-row", "market-header-child") menu = _mobile_nav_panel_html(ctx) return oob_page(ctx, oobs_html=oobs, content_html=content, menu_html=menu) @@ -1334,6 +1355,8 @@ async def render_browse_oob(ctx: dict) -> str: oobs = _oob_header_html("post-header-child", "market-header-child", _market_header_html(ctx)) oobs += _post_header_html(ctx, oob=True) + oobs += _clear_deeper_oob("post-row", "post-header-child", + "market-row", "market-header-child") menu = _mobile_nav_panel_html(ctx) filter_html = _mobile_filter_summary_html(ctx) aside_html = _desktop_filter_html(ctx) @@ -1369,6 +1392,9 @@ async def render_product_oob(ctx: dict, d: dict) -> str: oobs = _market_header_html(ctx, oob=True) oobs += _oob_header_html("market-header-child", "product-header-child", _product_header_html(ctx, d)) + oobs += _clear_deeper_oob("post-row", "post-header-child", + "market-row", "market-header-child", + "product-row", "product-header-child") menu = _mobile_nav_panel_html(ctx) return oob_page(ctx, oobs_html=oobs, content_html=content, menu_html=menu) @@ -1395,6 +1421,10 @@ async def render_product_admin_oob(ctx: dict, d: dict) -> str: oobs = _product_header_html(ctx, d, oob=True) oobs += _oob_header_html("product-header-child", "product-admin-header-child", _product_admin_header_html(ctx, d)) + oobs += _clear_deeper_oob("post-row", "post-header-child", + "market-row", "market-header-child", + "product-row", "product-header-child", + "product-admin-row", "product-admin-header-child") return oob_page(ctx, oobs_html=oobs, content_html=content) @@ -1433,6 +1463,9 @@ async def render_market_admin_oob(ctx: dict) -> str: oobs = _market_header_html(ctx, oob=True) oobs += _oob_header_html("market-header-child", "market-admin-header-child", _market_admin_header_html(ctx, selected="markets")) + oobs += _clear_deeper_oob("post-row", "post-header-child", + "market-row", "market-header-child", + "market-admin-row", "market-admin-header-child") return oob_page(ctx, oobs_html=oobs, content_html=content) @@ -1461,6 +1494,8 @@ async def render_page_admin_oob(ctx: dict) -> str: """OOB response: page-level market admin.""" slug = (ctx.get("post") or {}).get("slug", "") oobs = post_admin_header_html(ctx, slug, oob=True, selected="markets") + oobs += _clear_deeper_oob("post-row", "post-header-child", + "post-admin-row", "post-admin-header-child") content = '
Market admin
' return oob_page(ctx, oobs_html=oobs, content_html=content) diff --git a/shared/sexp/helpers.py b/shared/sexp/helpers.py index c808915..643d46d 100644 --- a/shared/sexp/helpers.py +++ b/shared/sexp/helpers.py @@ -41,6 +41,7 @@ def root_header_html(ctx: dict, *, oob: bool = False) -> str: cart_mini_html=ctx.get("cart_mini_html", ""), blog_url=call_url(ctx, "blog_url", ""), site_title=ctx.get("base_title", ""), + app_label=ctx.get("app_label", ""), nav_tree_html=ctx.get("nav_tree_html", ""), auth_menu_html=ctx.get("auth_menu_html", ""), nav_panel_html=ctx.get("nav_panel_html", ""),