Update reference docs: fix event names, add demos, document sx-boost target

- Remove sx:afterSettle (not dispatched), rename sx:sendError → sx:requestError
- Add sx:clientRoute event (Phase 3 client-side routing)
- Add working demos for all 10 events (afterRequest, afterSwap, requestError,
  clientRoute, sseOpen, sseMessage, sseError were missing demos)
- Update sx-boost docs: configurable target selector, client routing behavior
- Remove app-specific nav logic from orchestration.sx, use sx:clientRoute event
- Pass page content deps to sx_response for component loading after server fallback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 23:12:38 +00:00
parent b9003eacb2
commit eee2954559
9 changed files with 333 additions and 117 deletions

View File

@@ -737,6 +737,26 @@ document.body.addEventListener('click', function (e) {
// ============================================================================
// Client-side route nav selection
// - Updates aria-selected on sub-nav links after client-side routing
// - Scoped to <nav> elements (top-level section links are outside <nav>)
// ============================================================================
document.body.addEventListener('sx:clientRoute', function (e) {
var pathname = e.detail && e.detail.pathname;
if (!pathname) return;
// Deselect all sub-nav links (inside <nav> elements)
document.querySelectorAll('nav a[aria-selected]').forEach(function (a) {
a.setAttribute('aria-selected', 'false');
});
// Select the matching link
document.querySelectorAll('nav a[href="' + pathname + '"]').forEach(function (a) {
a.setAttribute('aria-selected', 'true');
});
});
// ============================================================================
// Scrolling menu arrow visibility (replaces hyperscript scroll/load handlers)
// Elements with data-scroll-arrows="arrow-class" show/hide arrows on overflow.

View File

@@ -14,7 +14,7 @@
// =========================================================================
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
var SX_VERSION = "2026-03-06T22:18:52Z";
var SX_VERSION = "2026-03-06T23:02:44Z";
function isNil(x) { return x === NIL || x === null || x === undefined; }
function isSxTruthy(x) { return x !== false && !isNil(x); }
@@ -1850,7 +1850,7 @@ return postSwap(target); });
var clientRouted = false;
if (isSxTruthy(isGetLink)) {
logInfo((String("sx:route trying ") + String(get(verbInfo, "url"))));
clientRouted = tryClientRoute(urlPathname(get(verbInfo, "url")));
clientRouted = tryClientRoute(urlPathname(get(verbInfo, "url")), domGetAttr(el, "sx-target"));
}
return (isSxTruthy(clientRouted) ? (browserPushState(get(verbInfo, "url")), browserScrollTo(0, 0)) : ((isSxTruthy(isGetLink) ? logInfo((String("sx:route server fetch ") + String(get(verbInfo, "url")))) : NIL), (isSxTruthy(get(mods, "delay")) ? (clearTimeout_(timer), (timer = setTimeout_(function() { return executeRequest(el, verbInfo, NIL); }, get(mods, "delay")))) : executeRequest(el, verbInfo, NIL))));
})()) : NIL);
@@ -1902,10 +1902,12 @@ return domAppendToHead(link); }, domQueryAll(container, "link[rel=\"stylesheet\"
var processBoosted = function(root) { return forEach(function(container) { return boostDescendants(container); }, domQueryAll(sxOr(root, domBody()), "[sx-boost]")); };
// boost-descendants
var boostDescendants = function(container) { { var _c = domQueryAll(container, "a[href]"); for (var _i = 0; _i < _c.length; _i++) { var link = _c[_i]; if (isSxTruthy((isSxTruthy(!isSxTruthy(isProcessed(link, "boost"))) && shouldBoostLink(link)))) {
var boostDescendants = function(container) { return (function() {
var boostTarget = domGetAttr(container, "sx-boost");
{ var _c = domQueryAll(container, "a[href]"); for (var _i = 0; _i < _c.length; _i++) { var link = _c[_i]; if (isSxTruthy((isSxTruthy(!isSxTruthy(isProcessed(link, "boost"))) && shouldBoostLink(link)))) {
markProcessed(link, "boost");
if (isSxTruthy(!isSxTruthy(domHasAttr(link, "sx-target")))) {
domSetAttr(link, "sx-target", "#main-panel");
if (isSxTruthy((isSxTruthy(!isSxTruthy(domHasAttr(link, "sx-target"))) && isSxTruthy(boostTarget) && !isSxTruthy((boostTarget == "true"))))) {
domSetAttr(link, "sx-target", boostTarget);
}
if (isSxTruthy(!isSxTruthy(domHasAttr(link, "sx-swap")))) {
domSetAttr(link, "sx-swap", "innerHTML");
@@ -1915,20 +1917,21 @@ return domAppendToHead(link); }, domQueryAll(container, "link[rel=\"stylesheet\"
}
bindClientRouteLink(link, domGetAttr(link, "href"));
} } }
return forEach(function(form) { return (isSxTruthy((isSxTruthy(!isSxTruthy(isProcessed(form, "boost"))) && shouldBoostForm(form))) ? (markProcessed(form, "boost"), (function() {
return forEach(function(form) { return (isSxTruthy((isSxTruthy(!isSxTruthy(isProcessed(form, "boost"))) && shouldBoostForm(form))) ? (markProcessed(form, "boost"), (function() {
var method = upper(sxOr(domGetAttr(form, "method"), "GET"));
var action = sxOr(domGetAttr(form, "action"), browserLocationHref());
if (isSxTruthy(!isSxTruthy(domHasAttr(form, "sx-target")))) {
domSetAttr(form, "sx-target", "#main-panel");
if (isSxTruthy((isSxTruthy(!isSxTruthy(domHasAttr(form, "sx-target"))) && isSxTruthy(boostTarget) && !isSxTruthy((boostTarget == "true"))))) {
domSetAttr(form, "sx-target", boostTarget);
}
if (isSxTruthy(!isSxTruthy(domHasAttr(form, "sx-swap")))) {
domSetAttr(form, "sx-swap", "innerHTML");
}
return bindBoostForm(form, method, action);
})()) : NIL); }, domQueryAll(container, "form")); };
})()) : NIL); }, domQueryAll(container, "form"));
})(); };
// try-client-route
var tryClientRoute = function(pathname) { return (function() {
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) : (isSxTruthy(get(match, "has-data")) ? (logInfo((String("sx:route server (has data) ") + String(pathname))), false) : (function() {
var contentSrc = get(match, "content");
@@ -1938,8 +1941,8 @@ return forEach(function(form) { return (isSxTruthy((isSxTruthy(!isSxTruthy(isPro
var env = merge(closure, params);
var rendered = tryEvalContent(contentSrc, env);
return (isSxTruthy(isNil(rendered)) ? (logInfo((String("sx:route server (eval failed) ") + String(pathname))), false) : (function() {
var target = domQueryById("main-panel");
return (isSxTruthy(isNil(target)) ? (logWarn("sx:route #main-panel not found"), false) : (domSetTextContent(target, ""), domAppend(target, rendered), hoistHeadElementsFull(target), processElements(target), sxHydrateElements(target), logInfo((String("sx:route client ") + String(pathname))), true));
var target = (isSxTruthy((isSxTruthy(targetSel) && !isSxTruthy((targetSel == "true")))) ? domQuery(targetSel) : NIL);
return (isSxTruthy(isNil(target)) ? (logWarn((String("sx:route target not found: ") + String(targetSel))), false) : (domSetTextContent(target, ""), domAppend(target, rendered), hoistHeadElementsFull(target), processElements(target), sxHydrateElements(target), domDispatch(target, "sx:clientRoute", {["pathname"]: pathname}), logInfo((String("sx:route client ") + String(pathname))), true));
})());
})());
})()));
@@ -2026,14 +2029,16 @@ return bindInlineHandlers(root); };
// handle-popstate
var handlePopstate = function(scrollY) { return (function() {
var main = domQueryById("main-panel");
var boostEl = domQuery("[sx-boost]");
var url = browserLocationHref();
return (isSxTruthy(main) ? (function() {
return (isSxTruthy(boostEl) ? (function() {
var targetSel = domGetAttr(boostEl, "sx-boost");
var target = (isSxTruthy((isSxTruthy(targetSel) && !isSxTruthy((targetSel == "true")))) ? domQuery(targetSel) : NIL);
var pathname = urlPathname(url);
return (isSxTruthy(tryClientRoute(pathname)) ? browserScrollTo(0, scrollY) : (function() {
var headers = buildRequestHeaders(main, loadedComponentNames(), _cssHash);
return fetchAndRestore(main, url, headers, scrollY);
})());
return (isSxTruthy(target) ? (isSxTruthy(tryClientRoute(pathname, targetSel)) ? browserScrollTo(0, scrollY) : (function() {
var headers = buildRequestHeaders(target, loadedComponentNames(), _cssHash);
return fetchAndRestore(target, url, headers, scrollY);
})()) : NIL);
})() : NIL);
})(); };
@@ -3106,7 +3111,7 @@ callExpr.push(dictGet(kwargs, k)); } }
}
return sxRenderWithEnv(source, merged);
} catch (e) {
console.error("[sx-ref] sx:route eval error for:", source, e);
logInfo("sx:route eval miss: " + (e && e.message ? e.message : e));
return NIL;
}
}

View File

@@ -456,14 +456,18 @@ def sx_call(component_name: str, **kwargs: Any) -> str:
def components_for_request(source: str = "") -> str:
def components_for_request(source: str = "",
extra_names: set[str] | None = None) -> str:
"""Return defcomp/defmacro source for definitions the client doesn't have yet.
Reads the ``SX-Components`` header (comma-separated component names
like ``~card,~nav-item``) and returns only the definitions the client
is missing. If *source* is provided, only sends components needed
for that source (plus transitive deps). If the header is absent,
returns all needed defs.
for that source (plus transitive deps).
*extra_names* — additional component names to include beyond what
*source* references. Used by defpage to send components the page's
content expression needs for client-side routing.
"""
from quart import request
from .jinja_bridge import _COMPONENT_ENV
@@ -477,6 +481,12 @@ def components_for_request(source: str = "") -> str:
else:
needed = None # all
# Merge in extra names (e.g. from page content expression deps)
if extra_names and needed is not None:
needed = needed | extra_names
elif extra_names:
needed = extra_names
loaded_raw = request.headers.get("SX-Components", "")
loaded = set(loaded_raw.split(",")) if loaded_raw else set()
@@ -510,7 +520,8 @@ def components_for_request(source: str = "") -> str:
def sx_response(source: str, status: int = 200,
headers: dict | None = None):
headers: dict | None = None,
extra_component_names: set[str] | None = None):
"""Return an s-expression wire-format response.
Takes a raw sx string::
@@ -520,6 +531,10 @@ def sx_response(source: str, status: int = 200,
For SX requests, missing component definitions are prepended as a
``<script type="text/sx" data-components>`` block so the client
can process them before rendering OOB content.
*extra_component_names* — additional component names to include beyond
what *source* references. Used by defpage to send components the page's
content expression needs for client-side routing.
"""
from quart import request, Response
@@ -535,7 +550,7 @@ def sx_response(source: str, status: int = 200,
# For SX requests, prepend missing component definitions
comp_defs = ""
if request.headers.get("SX-Request"):
comp_defs = components_for_request(source)
comp_defs = components_for_request(source, extra_names=extra_component_names)
if comp_defs:
body = (f'<script type="text/sx" data-components>'
f'{comp_defs}</script>\n{body}')

View File

@@ -279,13 +279,25 @@ async def execute_page(
is_htmx = is_htmx_request()
if is_htmx:
# Compute content expression deps so the server sends component
# definitions the client needs for future client-side routing
extra_deps: set[str] | None = None
if page_def.content_expr is not None and page_def.data_expr is None:
from .deps import components_needed
from .parser import serialize
try:
content_src = serialize(page_def.content_expr)
extra_deps = components_needed(content_src, get_component_env())
except Exception:
pass # non-critical — client will just fall back to server
return sx_response(await oob_page_sx(
oobs=oob_headers if oob_headers else "",
filter=filter_sx,
aside=aside_sx,
content=content_sx,
menu=menu_sx,
))
), extra_component_names=extra_deps)
else:
return await full_page_sx(
tctx,

View File

@@ -2632,7 +2632,7 @@ PLATFORM_ORCHESTRATION_JS = """
}
return sxRenderWithEnv(source, merged);
} catch (e) {
console.error("[sx-ref] sx:route eval error for:", source, e);
logInfo("sx:route eval miss: " + (e && e.message ? e.message : e));
return NIL;
}
}

View File

@@ -397,7 +397,9 @@
(when is-get-link
(log-info (str "sx:route trying " (get verbInfo "url")))
(set! client-routed
(try-client-route (url-pathname (get verbInfo "url")))))
(try-client-route
(url-pathname (get verbInfo "url"))
(dom-get-attr el "sx-target"))))
(if client-routed
(do
(browser-push-state (get verbInfo "url"))
@@ -507,48 +509,54 @@
(define boost-descendants
(fn (container)
;; Boost links and forms within a container
;; Links get sx-get, forms get sx-post/sx-get
(for-each
(fn (link)
(when (and (not (is-processed? link "boost"))
(should-boost-link? link))
(mark-processed! link "boost")
;; Set default sx-target if not specified
(when (not (dom-has-attr? link "sx-target"))
(dom-set-attr link "sx-target" "#main-panel"))
(when (not (dom-has-attr? link "sx-swap"))
(dom-set-attr link "sx-swap" "innerHTML"))
(when (not (dom-has-attr? link "sx-push-url"))
(dom-set-attr link "sx-push-url" "true"))
(bind-client-route-link link (dom-get-attr link "href"))))
(dom-query-all container "a[href]"))
(for-each
(fn (form)
(when (and (not (is-processed? form "boost"))
(should-boost-form? form))
(mark-processed! form "boost")
(let ((method (upper (or (dom-get-attr form "method") "GET")))
(action (or (dom-get-attr form "action")
(browser-location-href))))
(when (not (dom-has-attr? form "sx-target"))
(dom-set-attr form "sx-target" "#main-panel"))
(when (not (dom-has-attr? form "sx-swap"))
(dom-set-attr form "sx-swap" "innerHTML"))
(bind-boost-form form method action))))
(dom-query-all container "form"))))
;; Boost links and forms within a container.
;; The sx-boost attribute value is the default target selector
;; for boosted descendants (e.g. sx-boost="#main-panel").
(let ((boost-target (dom-get-attr container "sx-boost")))
(for-each
(fn (link)
(when (and (not (is-processed? link "boost"))
(should-boost-link? link))
(mark-processed! link "boost")
;; Inherit target from boost container if not specified
(when (and (not (dom-has-attr? link "sx-target"))
boost-target (not (= boost-target "true")))
(dom-set-attr link "sx-target" boost-target))
(when (not (dom-has-attr? link "sx-swap"))
(dom-set-attr link "sx-swap" "innerHTML"))
(when (not (dom-has-attr? link "sx-push-url"))
(dom-set-attr link "sx-push-url" "true"))
(bind-client-route-link link (dom-get-attr link "href"))))
(dom-query-all container "a[href]"))
(for-each
(fn (form)
(when (and (not (is-processed? form "boost"))
(should-boost-form? form))
(mark-processed! form "boost")
(let ((method (upper (or (dom-get-attr form "method") "GET")))
(action (or (dom-get-attr form "action")
(browser-location-href))))
(when (and (not (dom-has-attr? form "sx-target"))
boost-target (not (= boost-target "true")))
(dom-set-attr form "sx-target" boost-target))
(when (not (dom-has-attr? form "sx-swap"))
(dom-set-attr form "sx-swap" "innerHTML"))
(bind-boost-form form method action))))
(dom-query-all container "form")))))
;; --------------------------------------------------------------------------
;; Client-side routing
;; --------------------------------------------------------------------------
;; No app-specific nav update here — apps handle sx:clientRoute event.
(define try-client-route
(fn (pathname)
(fn (pathname target-sel)
;; 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).
;; Only works for pages without :data dependencies.
;; Uses try-eval-content which catches errors — if a component is missing,
;; eval fails and we fall back to server fetch transparently.
(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)
@@ -563,15 +571,19 @@
(rendered (try-eval-content content-src env)))
(if (nil? rendered)
(do (log-info (str "sx:route server (eval failed) " pathname)) false)
(let ((target (dom-query-by-id "main-panel")))
(let ((target (if (and target-sel (not (= target-sel "true")))
(dom-query target-sel)
nil)))
(if (nil? target)
(do (log-warn "sx:route #main-panel not found") false)
(do (log-warn (str "sx:route target not found: " target-sel)) false)
(do
(dom-set-text-content target "")
(dom-append target rendered)
(hoist-head-elements-full target)
(process-elements target)
(sx-hydrate-elements target)
(dom-dispatch target "sx:clientRoute"
(dict "pathname" pathname))
(log-info (str "sx:route client " pathname))
true))))))))))))
@@ -733,16 +745,22 @@
(define handle-popstate
(fn (scrollY)
;; Handle browser back/forward navigation.
;; Derive target from the nearest [sx-boost] container.
;; Try client-side route first, fall back to server fetch.
(let ((main (dom-query-by-id "main-panel"))
(let ((boost-el (dom-query "[sx-boost]"))
(url (browser-location-href)))
(when main
(let ((pathname (url-pathname url)))
(if (try-client-route pathname)
(browser-scroll-to 0 scrollY)
(let ((headers (build-request-headers main
(loaded-component-names) _css-hash)))
(fetch-and-restore main url headers scrollY))))))))
(when boost-el
(let ((target-sel (dom-get-attr boost-el "sx-boost"))
(target (if (and target-sel (not (= target-sel "true")))
(dom-query target-sel)
nil))
(pathname (url-pathname url)))
(when target
(if (try-client-route pathname target-sel)
(browser-scroll-to 0 scrollY)
(let ((headers (build-request-headers target
(loaded-component-names) _css-hash)))
(fetch-and-restore target url headers scrollY)))))))))
;; --------------------------------------------------------------------------
@@ -795,7 +813,7 @@
;; cross-origin
;; success-fn: (fn (resp-ok status get-header text) ...)
;; error-fn: (fn (err) ...)
;; (fetch-location url) → fetch URL and swap to #main-panel
;; (fetch-location url) → fetch URL and swap to boost target
;; (fetch-and-restore main url headers scroll-y) → popstate fetch+swap
;; (fetch-preload url headers cache) → preload into cache
;;