From 14c5316d17da943dd0ad7b88ca63b4d9067b9e14 Mon Sep 17 00:00:00 2001 From: giles Date: Fri, 6 Mar 2026 22:06:28 +0000 Subject: [PATCH] Add component deps to page registry, check before client routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each page entry now includes a deps list of component names needed. Client checks all deps are loaded before attempting eval — if any are missing, falls through to server fetch with a clear log message. No bundle bloat: server sends components for the current page only. Co-Authored-By: Claude Opus 4.6 --- shared/static/scripts/sx-browser.js | 17 +++++++++++++--- shared/sx/helpers.py | 12 +++++++++++- shared/sx/ref/orchestration.sx | 30 +++++++++++++++++++++++------ 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index ab3cb56..403c428 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-06T22:01:24Z"; + var SX_VERSION = "2026-03-06T22:06:19Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } @@ -1932,21 +1932,32 @@ return forEach(function(form) { return (isSxTruthy((isSxTruthy(!isSxTruthy(isPro return bindBoostForm(form, method, action); })()) : NIL); }, domQueryAll(container, "form")); }; + // has-all-deps? + var hasAllDeps_p = function(deps) { return (function() { + var loaded = loadedComponentNames(); + var missing = false; + { var _c = deps; for (var _i = 0; _i < _c.length; _i++) { var dep = _c[_i]; if (isSxTruthy(!isSxTruthy(contains(loaded, dep)))) { + missing = dep; +} } } + return (isSxTruthy(missing) ? (logInfo((String("sx:route missing component ") + String(missing))), false) : true); +})(); }; + // try-client-route var tryClientRoute = function(pathname) { 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"); + var deps = sxOr(get(match, "deps"), []); var closure = sxOr(get(match, "closure"), {}); var params = get(match, "params"); - return (isSxTruthy(sxOr(isNil(contentSrc), isEmpty(contentSrc))) ? (logWarn((String("sx:route no content for ") + String(pathname))), false) : (function() { + return (isSxTruthy(sxOr(isNil(contentSrc), isEmpty(contentSrc))) ? (logWarn((String("sx:route no content for ") + String(pathname))), false) : (isSxTruthy(!isSxTruthy(hasAllDeps_p(deps))) ? (logInfo((String("sx:route server (missing deps) ") + String(pathname))), false) : (function() { var env = merge(closure, params); var rendered = tryEvalContent(contentSrc, env); return (isSxTruthy(isNil(rendered)) ? (logWarn((String("sx:route eval failed for ") + 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)); })()); -})()); +})())); })())); })(); }; diff --git a/shared/sx/helpers.py b/shared/sx/helpers.py index 51d82a0..3174184 100644 --- a/shared/sx/helpers.py +++ b/shared/sx/helpers.py @@ -644,12 +644,14 @@ def _build_pages_sx(service: str) -> str: Returns SX dict literals (one per page) parseable by the client's ``parse`` function. Each dict has keys: name, path, auth, has-data, - content, closure. + content, closure, deps. """ import logging _log = logging.getLogger("sx.pages") from .pages import get_all_pages from .parser import serialize as sx_serialize + from .deps import components_needed + from .jinja_bridge import _COMPONENT_ENV pages = get_all_pages(service) _log.debug("_build_pages_sx(%s): %d pages in registry", service, len(pages)) @@ -666,6 +668,12 @@ def _build_pages_sx(service: str) -> str: auth = page_def.auth if isinstance(page_def.auth, str) else "custom" has_data = "true" if page_def.data_expr is not None else "false" + # Component deps needed to render this page client-side + deps: set[str] = set() + if content_src and page_def.data_expr is None: + deps = components_needed(content_src, _COMPONENT_ENV) + deps_sx = "(" + " ".join(_sx_literal(d) for d in sorted(deps)) + ")" + # Build closure as SX dict closure_parts: list[str] = [] for k, v in page_def.closure.items(): @@ -679,6 +687,7 @@ def _build_pages_sx(service: str) -> str: + " :auth " + _sx_literal(auth) + " :has-data " + has_data + " :content " + _sx_literal(content_src) + + " :deps " + deps_sx + " :closure " + closure_sx + "}" ) entries.append(entry) @@ -702,6 +711,7 @@ def _sx_literal(v: object) -> str: return "nil" + def sx_page(ctx: dict, page_sx: str, *, meta_html: str = "") -> str: """Return a minimal HTML shell that boots the page from sx source. diff --git a/shared/sx/ref/orchestration.sx b/shared/sx/ref/orchestration.sx index 695a4f5..b084fb2 100644 --- a/shared/sx/ref/orchestration.sx +++ b/shared/sx/ref/orchestration.sx @@ -543,24 +543,42 @@ ;; Client-side routing ;; -------------------------------------------------------------------------- +(define has-all-deps? + (fn (deps) + ;; Check if all component deps are loaded in the client env. + ;; deps is a list of component name strings (e.g. "~essay-foo"). + (let ((loaded (loaded-component-names)) + (missing false)) + (for-each + (fn (dep) + (when (not (contains? loaded dep)) + (set! missing dep))) + deps) + (if missing + (do (log-info (str "sx:route missing component " missing)) false) + true)))) + (define try-client-route (fn (pathname) ;; Try to render a page client-side. Returns true if successful, false otherwise. - ;; Only works for pages without :data dependencies. + ;; Only works for pages without :data dependencies and with all deps loaded. (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) (if (get match "has-data") (do (log-info (str "sx:route server (has data) " pathname)) false) (let ((content-src (get match "content")) + (deps (or (get match "deps") (list))) (closure (or (get match "closure") {})) (params (get match "params"))) (if (or (nil? content-src) (empty? content-src)) (do (log-warn (str "sx:route no content for " pathname)) false) - (let ((env (merge closure params)) - (rendered (try-eval-content content-src env))) - (if (nil? rendered) - (do (log-warn (str "sx:route eval failed for " pathname)) false) + (if (not (has-all-deps? deps)) + (do (log-info (str "sx:route server (missing deps) " pathname)) false) + (let ((env (merge closure params)) + (rendered (try-eval-content content-src env))) + (if (nil? rendered) + (do (log-warn (str "sx:route eval failed for " pathname)) false) (let ((target (dom-query-by-id "main-panel"))) (if (nil? target) (do (log-warn "sx:route #main-panel not found") false) @@ -571,7 +589,7 @@ (process-elements target) (sx-hydrate-elements target) (log-info (str "sx:route client " pathname)) - true)))))))))))) + true))))))))))))) (define bind-client-route-link