diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index 279e950..9c310a9 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-07T23:47:29Z"; + var SX_VERSION = "2026-03-07T23:59:03Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } diff --git a/shared/sx/deps.py b/shared/sx/deps.py index ceeee4a..b316a9e 100644 --- a/shared/sx/deps.py +++ b/shared/sx/deps.py @@ -198,6 +198,22 @@ def compute_all_io_refs(env: dict[str, Any], io_names: set[str]) -> None: _compute_all_io_refs_fallback(env, io_names) +def page_render_plan(page_sx: str, env: dict[str, Any], io_names: set[str] | None = None) -> dict[str, Any]: + """Compute the render plan for a page. + + Returns dict with: + - "components": {name: "server"|"client", ...} + - "server": [names rendered server-side] + - "client": [names rendered client-side] + - "io-deps": [IO primitive names needed by server components] + """ + if io_names is None: + io_names = get_all_io_names() + from .ref.sx_ref import page_render_plan as _ref_prp + plan = _ref_prp(page_sx, env, list(io_names)) + return plan + + def get_all_io_names() -> set[str]: """Build the complete set of IO primitive names from all boundary tiers. diff --git a/shared/sx/helpers.py b/shared/sx/helpers.py index 773b223..4417b6e 100644 --- a/shared/sx/helpers.py +++ b/shared/sx/helpers.py @@ -738,6 +738,15 @@ def _build_pages_sx(service: str) -> str: stream = "true" if page_def.stream else "false" + # Render plan: which components render where + plan = page_def.render_plan + if plan: + server_sx = "(" + " ".join(_sx_literal(n) for n in plan.get("server", [])) + ")" + client_sx = "(" + " ".join(_sx_literal(n) for n in plan.get("client", [])) + ")" + render_plan_sx = "{:server " + server_sx + " :client " + client_sx + "}" + else: + render_plan_sx = "{:server () :client ()}" + entry = ( "{:name " + _sx_literal(page_def.name) + " :path " + _sx_literal(page_def.path) @@ -746,6 +755,7 @@ def _build_pages_sx(service: str) -> str: + " :stream " + stream + " :layout " + _sx_literal(layout_id) + " :io-deps " + io_deps_sx + + " :render-plan " + render_plan_sx + " :content " + _sx_literal(content_src) + " :deps " + deps_sx + " :closure " + closure_sx + "}" diff --git a/shared/sx/pages.py b/shared/sx/pages.py index d505793..b6958b5 100644 --- a/shared/sx/pages.py +++ b/shared/sx/pages.py @@ -840,6 +840,25 @@ async def execute_page_streaming_oob( # Blueprint mounting # --------------------------------------------------------------------------- +def compute_page_render_plans(service_name: str) -> None: + """Pre-compute and cache render plans for all pages in a service. + + Must be called after components are loaded (compute_all_deps/io_refs done) + and pages are registered. Stores plans on PageDef.render_plan. + """ + from .parser import serialize + from .deps import page_render_plan, get_all_io_names + from .jinja_bridge import _COMPONENT_ENV + + io_names = get_all_io_names() + pages = get_all_pages(service_name) + for page_def in pages.values(): + if page_def.content_expr is not None: + content_src = serialize(page_def.content_expr) + page_def.render_plan = page_render_plan(content_src, _COMPONENT_ENV, io_names) + logger.info("Computed render plans for %d pages in %s", len(pages), service_name) + + def auto_mount_pages(app: Any, service_name: str) -> None: """Auto-mount all registered defpages for a service directly on the app. @@ -849,6 +868,10 @@ def auto_mount_pages(app: Any, service_name: str) -> None: Also mounts the /sx/data/ endpoint for client-side data fetching. """ pages = get_all_pages(service_name) + + # Pre-compute render plans (which components render where) + compute_page_render_plans(service_name) + for page_def in pages.values(): _mount_one_page(app, service_name, page_def) logger.info("Auto-mounted %d defpages for %s", len(pages), service_name) diff --git a/shared/sx/ref/bootstrap_js.py b/shared/sx/ref/bootstrap_js.py index 07a41a3..b6bef26 100644 --- a/shared/sx/ref/bootstrap_js.py +++ b/shared/sx/ref/bootstrap_js.py @@ -549,6 +549,7 @@ class JSEmitter: "compute-all-io-refs": "computeAllIoRefs", "component-pure?": "componentPure_p", "render-target": "renderTarget", + "page-render-plan": "pageRenderPlan", # router.sx "split-path-segments": "splitPathSegments", "make-route-segment": "makeRouteSegment", diff --git a/shared/sx/ref/bootstrap_py.py b/shared/sx/ref/bootstrap_py.py index eecc85f..e948307 100644 --- a/shared/sx/ref/bootstrap_py.py +++ b/shared/sx/ref/bootstrap_py.py @@ -278,6 +278,7 @@ class PyEmitter: "compute-all-io-refs": "compute_all_io_refs", "component-pure?": "component_pure_p", "render-target": "render_target", + "page-render-plan": "page_render_plan", # router.sx "split-path-segments": "split_path_segments", "make-route-segment": "make_route_segment", diff --git a/shared/sx/ref/deps.sx b/shared/sx/ref/deps.sx index ba09f76..b0190d8 100644 --- a/shared/sx/ref/deps.sx +++ b/shared/sx/ref/deps.sx @@ -341,6 +341,50 @@ :else "client"))))))) +;; -------------------------------------------------------------------------- +;; 6. Page render plan — pre-computed boundary decisions for a page +;; -------------------------------------------------------------------------- +;; Given page source + env + IO names, returns a render plan dict: +;; +;; {:components {~name "server"|"client" ...} +;; :server (list of ~names that render server-side) +;; :client (list of ~names that render client-side) +;; :io-deps (list of IO primitives needed by server components)} +;; +;; This is computed once at page registration and cached on the page def. +;; The async evaluator and client router both use it to make decisions +;; without recomputing at every request. + +(define page-render-plan + (fn (page-source env io-names) + (let ((needed (components-needed page-source env)) + (comp-targets (dict)) + (server-list (list)) + (client-list (list)) + (io-deps (list))) + + (for-each + (fn (name) + (let ((target (render-target name env io-names))) + (dict-set! comp-targets name target) + (if (= target "server") + (do + (append! server-list name) + ;; Collect IO deps from server components + (for-each + (fn (io-ref) + (when (not (contains? io-deps io-ref)) + (append! io-deps io-ref))) + (transitive-io-refs name env io-names))) + (append! client-list name)))) + needed) + + {:components comp-targets + :server server-list + :client client-list + :io-deps io-deps}))) + + ;; -------------------------------------------------------------------------- ;; Host obligation: selective expansion in async partial evaluation ;; -------------------------------------------------------------------------- diff --git a/shared/sx/ref/sx_ref.py b/shared/sx/ref/sx_ref.py index 3fcc7d9..21e6b3c 100644 --- a/shared/sx/ref/sx_ref.py +++ b/shared/sx/ref/sx_ref.py @@ -1301,6 +1301,9 @@ component_pure_p = lambda name, env, io_names: empty_p(transitive_io_refs(name, # render-target render_target = lambda name, env, io_names: (lambda key: (lambda val: ('server' if sx_truthy((not sx_truthy((type_of(val) == 'component')))) else (lambda affinity: ('server' if sx_truthy((affinity == 'server')) else ('client' if sx_truthy((affinity == 'client')) else ('server' if sx_truthy((not sx_truthy(component_pure_p(name, env, io_names)))) else 'client'))))(component_affinity(val))))(env_get(env, key)))((name if sx_truthy(starts_with_p(name, '~')) else sx_str('~', name))) +# page-render-plan +page_render_plan = lambda page_source, env, io_names: (lambda needed: (lambda comp_targets: (lambda server_list: (lambda client_list: (lambda io_deps: _sx_begin(for_each(lambda name: (lambda target: _sx_begin(_sx_dict_set(comp_targets, name, target), (_sx_begin(_sx_append(server_list, name), for_each(lambda io_ref: (_sx_append(io_deps, io_ref) if sx_truthy((not sx_truthy(contains_p(io_deps, io_ref)))) else NIL), transitive_io_refs(name, env, io_names))) if sx_truthy((target == 'server')) else _sx_append(client_list, name))))(render_target(name, env, io_names)), needed), {'components': comp_targets, 'server': server_list, 'client': client_list, 'io-deps': io_deps}))([]))([]))([]))({}))(components_needed(page_source, env)) + # ========================================================================= # Fixups -- wire up render adapter dispatch diff --git a/shared/sx/ref/test-deps.sx b/shared/sx/ref/test-deps.sx index ba9dad6..b35e3b3 100644 --- a/shared/sx/ref/test-deps.sx +++ b/shared/sx/ref/test-deps.sx @@ -263,3 +263,43 @@ (deftest "unknown name targets server" (assert-equal "server" (render-target "~nonexistent" (test-env) (list "fetch-data"))))) + + +;; -------------------------------------------------------------------------- +;; 7. page-render-plan — per-page boundary plan +;; -------------------------------------------------------------------------- + +;; A page component that uses both pure and IO components +(defcomp ~plan-page (&key data) + (div + (~dep-auto-pure :x "hello") + (~dep-auto-io :x data) + (~dep-force-client :x "interactive"))) + +(defsuite "page-render-plan" + + (deftest "plan classifies components correctly" + (let ((plan (page-render-plan "(~plan-page :data d)" (test-env) (list "fetch-data")))) + ;; ~plan-page has transitive IO deps (via ~dep-auto-io) so targets server + (assert-equal "server" (dict-get (get plan :components) "~plan-page")) + (assert-equal "client" (dict-get (get plan :components) "~dep-auto-pure")) + (assert-equal "server" (dict-get (get plan :components) "~dep-auto-io")) + (assert-equal "client" (dict-get (get plan :components) "~dep-force-client")))) + + (deftest "plan server list contains IO components" + (let ((plan (page-render-plan "(~plan-page :data d)" (test-env) (list "fetch-data")))) + (assert-true (contains? (get plan :server) "~dep-auto-io")))) + + (deftest "plan client list contains pure components" + (let ((plan (page-render-plan "(~plan-page :data d)" (test-env) (list "fetch-data")))) + (assert-true (contains? (get plan :client) "~dep-auto-pure")) + (assert-true (contains? (get plan :client) "~dep-force-client")))) + + (deftest "plan collects IO deps from server components" + (let ((plan (page-render-plan "(~plan-page :data d)" (test-env) (list "fetch-data")))) + (assert-true (contains? (get plan :io-deps) "fetch-data")))) + + (deftest "pure-only page has empty server list" + (let ((plan (page-render-plan "(~dep-auto-pure :x 1)" (test-env) (list "fetch-data")))) + (assert-equal 0 (len (get plan :server))) + (assert-true (> (len (get plan :client)) 0))))) diff --git a/shared/sx/tests/run.py b/shared/sx/tests/run.py index 455cc0f..63ac207 100644 --- a/shared/sx/tests/run.py +++ b/shared/sx/tests/run.py @@ -289,6 +289,7 @@ def _load_deps_from_bootstrap(env): compute_all_io_refs, component_pure_p, render_target, + page_render_plan, ) env["scan-refs"] = scan_refs env["scan-components-from-source"] = scan_components_from_source @@ -302,6 +303,7 @@ def _load_deps_from_bootstrap(env): env["compute-all-io-refs"] = compute_all_io_refs env["component-pure?"] = component_pure_p env["render-target"] = render_target + env["page-render-plan"] = page_render_plan env["test-env"] = lambda: env except ImportError: eval_file("deps.sx", env) diff --git a/shared/sx/types.py b/shared/sx/types.py index 594d569..6a3eece 100644 --- a/shared/sx/types.py +++ b/shared/sx/types.py @@ -255,6 +255,7 @@ class PageDef: fallback_expr: Any = None # fallback content while streaming shell_expr: Any = None # immediate shell content (wraps suspense) closure: dict[str, Any] = field(default_factory=dict) + render_plan: dict[str, Any] | None = field(default=None, repr=False) _FIELD_MAP = { "name": "name", "path": "path", "auth": "auth", diff --git a/sx/sx/affinity-demo.sx b/sx/sx/affinity-demo.sx index 659fe6f..d4bf9b5 100644 --- a/sx/sx/affinity-demo.sx +++ b/sx/sx/affinity-demo.sx @@ -49,7 +49,7 @@ ;; --- Main page component --- -(defcomp ~affinity-demo-content (&key components) +(defcomp ~affinity-demo-content (&key components page-plans) (div :class "space-y-8" (div :class "border-b border-stone-200 pb-6" (h1 :class "text-2xl font-bold text-stone-900" "Affinity Annotations") @@ -154,6 +154,35 @@ (td :class "px-3 py-2 font-bold text-orange-700" "server") (td :class "px-3 py-2 text-stone-600" "Both affinity and IO say server")))))) + ;; Per-page render plans + (~doc-section :title "Page Render Plans" :id "plans" + (p "Phase 7b: render plans are pre-computed at registration time for each page. The plan maps every component needed by the page to its render target.") + + (when (> (len page-plans) 0) + (div :class "space-y-4 mt-4" + (map (fn (plan) + (div :class "rounded border border-stone-200 p-4" + (div :class "flex items-center justify-between mb-3" + (div + (span :class "font-mono font-medium text-stone-800" (get plan "name")) + (span :class "text-stone-400 ml-2 text-sm" (get plan "path"))) + (div :class "flex gap-2" + (span :class "inline-block px-2 py-0.5 rounded text-xs font-bold bg-orange-100 text-orange-700" + (str (get plan "server-count") " server")) + (span :class "inline-block px-2 py-0.5 rounded text-xs font-bold bg-green-100 text-green-700" + (str (get plan "client-count") " client")))) + (when (> (get plan "server-count") 0) + (div :class "mb-2" + (span :class "text-xs font-medium text-stone-500 uppercase" "Server-expanded: ") + (span :class "text-sm font-mono text-orange-700" + (join " " (get plan "server"))))) + (when (> (get plan "client-count") 0) + (div + (span :class "text-xs font-medium text-stone-500 uppercase" "Client-rendered: ") + (span :class "text-sm font-mono text-green-700" + (join " " (get plan "client"))))))) + page-plans)))) + ;; How it integrates (~doc-section :title "How It Works" :id "how" (ol :class "list-decimal list-inside text-stone-700 space-y-2" diff --git a/sx/sx/plans.sx b/sx/sx/plans.sx index fc0a627..5c5b742 100644 --- a/sx/sx/plans.sx +++ b/sx/sx/plans.sx @@ -2014,8 +2014,30 @@ (li "Backward compatible: existing defcomp without :affinity defaults to \"auto\"")))) (~doc-subsection :title "7b. Runtime Boundary Optimizer" + + (div :class "rounded border border-green-300 bg-green-50 p-3 mb-4" + (div :class "flex items-center gap-2 mb-1" + (span :class "inline-block px-2 py-0.5 rounded text-xs font-bold bg-green-600 text-white uppercase" "Complete")) + (p :class "text-green-800 text-sm" "Per-page render plans computed at registration time. Each page knows exactly which components render server-side vs client-side, cached on PageDef.")) + (p "Given component tree + IO dependency graph + affinity annotations, decide per-component: server-expand, client-render, or stream. Planning step cached at registration, recomputed on component change.") - (p :class "text-stone-500 text-sm italic" "Next: integrate render-target into the bundle analyzer, page registry, and orchestration.sx.")) + + (p (code "page-render-plan") " in deps.sx computes per-page boundary decisions:") + (~doc-code :code (highlight "(page-render-plan page-source env io-names)\n;; Returns:\n;; {:components {~name \"server\"|\"client\" ...}\n;; :server (list of server-expanded names)\n;; :client (list of client-rendered names)\n;; :io-deps (IO primitives needed by server components)}" "lisp")) + + (~doc-subsection :title "Integration Points" + (ul :class "list-disc pl-5 text-stone-700 space-y-1" + (li (code "shared/sx/ref/deps.sx") " — " (code "page-render-plan") " spec function") + (li (code "shared/sx/deps.py") " — Python wrapper, dispatches to bootstrapped code") + (li (code "shared/sx/pages.py") " — " (code "compute_page_render_plans()") " called at mount time, caches on PageDef") + (li (code "shared/sx/helpers.py") " — " (code "_build_pages_sx()") " includes " (code ":render-plan") " in client page registry") + (li (code "shared/sx/types.py") " — " (code "PageDef.render_plan") " field"))) + + (~doc-subsection :title "Verification" + (ul :class "list-disc pl-5 text-stone-700 space-y-1" + (li "5 new spec tests (page-render-plan suite)") + (li "Render plans visible on " (a :href "/isomorphism/affinity" "affinity demo page")) + (li "Client page registry includes :render-plan for each page")))) (~doc-subsection :title "7c. Optimistic Data Updates" (p "Extend existing apply-optimistic/revert-optimistic in engine.sx from DOM-level to data-level. Client updates cached data optimistically, sends mutation to server, reverts on rejection.")) diff --git a/sx/sxc/pages/helpers.py b/sx/sxc/pages/helpers.py index b8073a3..de0c64a 100644 --- a/sx/sxc/pages/helpers.py +++ b/sx/sxc/pages/helpers.py @@ -881,9 +881,10 @@ async def _streaming_demo_data(): def _affinity_demo_data() -> dict: - """Return affinity analysis for the demo components.""" + """Return affinity analysis for the demo components + page render plans.""" from shared.sx.jinja_bridge import get_component_env from shared.sx.types import Component + from shared.sx.pages import get_all_pages env = get_component_env() demo_names = [ @@ -904,4 +905,20 @@ def _affinity_demo_data() -> dict: "io-refs": sorted(val.io_refs), "is-pure": val.is_pure, }) - return {"components": components} + + # Collect render plans from all sx service pages + page_plans = [] + for page_def in get_all_pages("sx").values(): + plan = page_def.render_plan + if plan: + page_plans.append({ + "name": page_def.name, + "path": page_def.path, + "server-count": len(plan.get("server", [])), + "client-count": len(plan.get("client", [])), + "server": plan.get("server", []), + "client": plan.get("client", []), + "io-deps": plan.get("io-deps", []), + }) + + return {"components": components, "page-plans": page_plans}