From 605aafa2ebadad1b6437bbe8e3cbe6804f9cc8f9 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 7 Mar 2026 17:46:01 +0000 Subject: [PATCH] Fix client routing: fall through to server on layout/section change Client-side routing was only swapping #main-panel content without updating OOB headers (nav rows, sub-rows). Now each page entry in the registry includes a layout identity (e.g. "sx-section:Testing") and try-client-route falls through to server when layout changes, so OOB header updates are applied correctly. Co-Authored-By: Claude Opus 4.6 --- shared/static/scripts/sx-browser.js | 13 +++++++++- shared/sx/helpers.py | 26 ++++++++++++++++++++ shared/sx/ref/orchestration.sx | 38 +++++++++++++++++++---------- 3 files changed, 63 insertions(+), 14 deletions(-) diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index 4488529..d7218b3 100644 --- a/shared/static/scripts/sx-browser.js +++ b/shared/static/scripts/sx-browser.js @@ -14,7 +14,7 @@ // ========================================================================= var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } }); - var SX_VERSION = "2026-03-07T17:30:45Z"; + var SX_VERSION = "2026-03-07T17:45:49Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } @@ -2007,6 +2007,13 @@ return domAppendToHead(link); }, domQueryAll(container, "link[rel=\"stylesheet\" // page-data-cache-set var pageDataCacheSet = function(cacheKey, data) { return dictSet(_pageDataCache, cacheKey, {"data": data, "ts": nowMs()}); }; + // current-page-layout + var currentPageLayout = function() { return (function() { + var pathname = urlPathname(browserLocationHref()); + var match = findMatchingRoute(pathname, _pageRoutes); + return (isSxTruthy(isNil(match)) ? "" : sxOr(get(match, "layout"), "")); +})(); }; + // swap-rendered-content var swapRenderedContent = function(target, rendered, pathname) { return (domSetTextContent(target, ""), domAppend(target, rendered), hoistHeadElementsFull(target), processElements(target), sxHydrateElements(target), domDispatch(target, "sx:clientRoute", {["pathname"]: pathname}), logInfo((String("sx:route client ") + String(pathname)))); }; @@ -2024,6 +2031,9 @@ return domAppendToHead(link); }, domQueryAll(container, "link[rel=\"stylesheet\" var tryClientRoute = function(pathname, targetSel) { return (function() { var match = findMatchingRoute(pathname, _pageRoutes); return (isSxTruthy(isNil(match)) ? (logInfo((String("sx:route no match (") + String(len(_pageRoutes)) + String(" routes) ") + String(pathname))), false) : (function() { + var targetLayout = sxOr(get(match, "layout"), ""); + var curLayout = currentPageLayout(); + return (isSxTruthy(!isSxTruthy((targetLayout == curLayout))) ? (logInfo((String("sx:route server (layout: ") + String(curLayout) + String(" -> ") + String(targetLayout) + String(") ") + String(pathname))), false) : (function() { var contentSrc = get(match, "content"); var closure = sxOr(get(match, "closure"), {}); var params = get(match, "params"); @@ -2061,6 +2071,7 @@ return (function() { })())); })()); })()); +})()); })(); }; // bind-client-route-link diff --git a/shared/sx/helpers.py b/shared/sx/helpers.py index eec3cf4..7004523 100644 --- a/shared/sx/helpers.py +++ b/shared/sx/helpers.py @@ -704,6 +704,31 @@ def _build_pages_sx(service: str) -> str: if io_deps else "()" ) + # Extract layout identity for client-side routing. + # When layout changes between pages, client routing falls through + # to server so OOB header updates are applied. + layout_id = "" + if isinstance(page_def.layout, str): + layout_id = page_def.layout + elif isinstance(page_def.layout, list): + from .types import Keyword as _Kw, Symbol as _Sym + first = page_def.layout[0] + if isinstance(first, _Kw): + layout_id = first.name + elif isinstance(first, _Sym): + layout_id = first.name + else: + layout_id = str(first) + # Append section kwarg to distinguish same-layout-type + # with different sections (e.g. sx-section+Docs vs sx-section+Testing) + raw_layout = page_def.layout + for li in range(1, len(raw_layout) - 1): + if isinstance(raw_layout[li], _Kw) and raw_layout[li].name == "section": + val = raw_layout[li + 1] + if val is not None: + layout_id = f"{layout_id}:{val}" + break + # Build closure as SX dict closure_parts: list[str] = [] for k, v in page_def.closure.items(): @@ -719,6 +744,7 @@ def _build_pages_sx(service: str) -> str: + " :auth " + _sx_literal(auth) + " :has-data " + has_data + " :stream " + stream + + " :layout " + _sx_literal(layout_id) + " :io-deps " + io_deps_sx + " :content " + _sx_literal(content_src) + " :deps " + deps_sx diff --git a/shared/sx/ref/orchestration.sx b/shared/sx/ref/orchestration.sx index 2272a3a..7440d93 100644 --- a/shared/sx/ref/orchestration.sx +++ b/shared/sx/ref/orchestration.sx @@ -593,7 +593,14 @@ ;; Client-side routing ;; -------------------------------------------------------------------------- -;; No app-specific nav update here — apps handle sx:clientRoute event. +(define current-page-layout + (fn () + ;; Find the layout name of the currently displayed page by matching + ;; the browser URL against the page route table. + (let ((pathname (url-pathname (browser-location-href))) + (match (find-matching-route pathname _page-routes))) + (if (nil? match) "" + (or (get match "layout") ""))))) (define swap-rendered-content @@ -634,20 +641,25 @@ ;; Try to render a page client-side. Returns true if successful, false otherwise. ;; target-sel is the CSS selector for the swap target (from sx-boost value). ;; For pure pages: renders immediately. For :data pages: fetches data then renders. + ;; Falls through to server when layout changes (needs OOB header update). (let ((match (find-matching-route pathname _page-routes))) (if (nil? match) (do (log-info (str "sx:route no match (" (len _page-routes) " routes) " pathname)) false) - (let ((content-src (get match "content")) - (closure (or (get match "closure") {})) - (params (get match "params")) - (page-name (get match "name"))) - (if (or (nil? content-src) (empty? content-src)) - (do (log-warn (str "sx:route no content for " pathname)) false) - (let ((target (resolve-route-target target-sel))) - (if (nil? target) - (do (log-warn (str "sx:route target not found: " target-sel)) false) - (if (not (deps-satisfied? match)) - (do (log-info (str "sx:route deps miss for " page-name)) false) + (let ((target-layout (or (get match "layout") "")) + (cur-layout (current-page-layout))) + (if (not (= target-layout cur-layout)) + (do (log-info (str "sx:route server (layout: " cur-layout " -> " target-layout ") " pathname)) false) + (let ((content-src (get match "content")) + (closure (or (get match "closure") {})) + (params (get match "params")) + (page-name (get match "name"))) + (if (or (nil? content-src) (empty? content-src)) + (do (log-warn (str "sx:route no content for " pathname)) false) + (let ((target (resolve-route-target target-sel))) + (if (nil? target) + (do (log-warn (str "sx:route target not found: " target-sel)) false) + (if (not (deps-satisfied? match)) + (do (log-info (str "sx:route deps miss for " page-name)) false) (let ((io-deps (get match "io-deps")) (has-io (and io-deps (not (empty? io-deps))))) ;; Ensure IO deps are registered as proxied primitives @@ -715,7 +727,7 @@ (do (log-info (str "sx:route server (eval failed) " pathname)) false) (do (swap-rendered-content target rendered pathname) - true))))))))))))))) + true))))))))))))))))) (define bind-client-route-link