From 2866bcbfc38c94242842706320405df2508299d8 Mon Sep 17 00:00:00 2001 From: giles Date: Fri, 6 Mar 2026 11:19:10 +0000 Subject: [PATCH] Implement isomorphic Phase 1: per-page component bundling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add component dependency analyzer (shared/sx/deps.py) that walks component AST bodies to compute transitive dependency sets. sx_page() and sx_response() now send only the components each page needs instead of the entire registry. Changes: - New: shared/sx/deps.py — transitive_deps(), components_needed(), scan_components_from_sx(), compute_all_deps() - shared/sx/types.py — Add deps: set[str] field to Component - shared/sx/jinja_bridge.py — Compute deps on registration, add components_for_page() and css_classes_for_page() - shared/sx/helpers.py — sx_page() uses per-page bundle + hash, sx_response() passes source to components_for_request() for page-scoped component diffing - New: shared/sx/tests/test_deps.py — 15 tests covering AST scanning, transitive deps, circular refs, per-page bundling Co-Authored-By: Claude Opus 4.6 --- shared/sx/deps.py | 114 +++++++++++++++++++++++ shared/sx/helpers.py | 62 ++++++------ shared/sx/jinja_bridge.py | 69 ++++++++++++++ shared/sx/tests/test_deps.py | 176 +++++++++++++++++++++++++++++++++++ shared/sx/types.py | 1 + 5 files changed, 386 insertions(+), 36 deletions(-) create mode 100644 shared/sx/deps.py create mode 100644 shared/sx/tests/test_deps.py diff --git a/shared/sx/deps.py b/shared/sx/deps.py new file mode 100644 index 0000000..31a53ad --- /dev/null +++ b/shared/sx/deps.py @@ -0,0 +1,114 @@ +""" +Component dependency analysis. + +Walks component AST bodies to compute transitive dependency sets. +A component's deps are all other components (~name references) it +can potentially render, including through control flow branches. +""" +from __future__ import annotations + +from typing import Any + +from .types import Component, Macro, Symbol + + +def _scan_ast(node: Any) -> set[str]: + """Scan an AST node for ~component references. + + Walks all branches of control flow (if/when/cond/case) to find + every component that *could* be rendered. Returns a set of + component names (with ~ prefix). + """ + refs: set[str] = set() + _walk(node, refs) + return refs + + +def _walk(node: Any, refs: set[str]) -> None: + """Recursively walk an AST node collecting ~name references.""" + if isinstance(node, Symbol): + if node.name.startswith("~"): + refs.add(node.name) + return + + if isinstance(node, list): + for item in node: + _walk(item, refs) + return + + if isinstance(node, dict): + for v in node.values(): + _walk(v, refs) + return + + # Literals (str, int, float, bool, None, Keyword) — no refs + return + + +def transitive_deps(name: str, env: dict[str, Any]) -> set[str]: + """Compute transitive component dependencies for *name*. + + Returns the set of all component names (with ~ prefix) that + *name* can transitively render, NOT including *name* itself. + """ + seen: set[str] = set() + + def walk(n: str) -> None: + if n in seen: + return + seen.add(n) + val = env.get(n) + if isinstance(val, Component): + for dep in _scan_ast(val.body): + walk(dep) + elif isinstance(val, Macro): + for dep in _scan_ast(val.body): + walk(dep) + + key = name if name.startswith("~") else f"~{name}" + walk(key) + return seen - {key} + + +def compute_all_deps(env: dict[str, Any]) -> None: + """Compute and cache deps for all Component entries in *env*. + + Mutates each Component's ``deps`` field in place. + """ + for key, val in env.items(): + if isinstance(val, Component): + val.deps = transitive_deps(key, env) + + +def scan_components_from_sx(source: str) -> set[str]: + """Extract component names referenced in SX source text. + + Uses regex to find (~name patterns in serialized SX wire format. + Returns names with ~ prefix, e.g. {"~card", "~nav-link"}. + """ + import re + return set(re.findall(r'\(~([a-zA-Z_][a-zA-Z0-9_\-]*)', source)) + + +def components_needed(page_sx: str, env: dict[str, Any]) -> set[str]: + """Compute the full set of component names needed for a page. + + Scans *page_sx* for direct component references, then computes + the transitive closure over the component dependency graph. + Returns names with ~ prefix. + """ + # Direct refs from the page source + direct = {f"~{n}" for n in scan_components_from_sx(page_sx)} + + # Transitive closure + all_needed: set[str] = set() + for name in direct: + all_needed.add(name) + val = env.get(name) + if isinstance(val, Component) and val.deps: + all_needed.update(val.deps) + else: + # deps not cached yet — compute on the fly + all_needed.update(transitive_deps(name, env)) + + return all_needed diff --git a/shared/sx/helpers.py b/shared/sx/helpers.py index c913ce7..88654e4 100644 --- a/shared/sx/helpers.py +++ b/shared/sx/helpers.py @@ -456,34 +456,39 @@ def sx_call(component_name: str, **kwargs: Any) -> str: -def components_for_request() -> str: +def components_for_request(source: str = "") -> 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 the header is absent, returns all defs. + 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. """ from quart import request - from .jinja_bridge import client_components_tag, _COMPONENT_ENV + from .jinja_bridge import _COMPONENT_ENV + from .deps import components_needed from .types import Component, Macro from .parser import serialize - loaded_raw = request.headers.get("SX-Components", "") - if not loaded_raw: - # Client has nothing — send all - tag = client_components_tag() - if not tag: - return "" - start = tag.find(">") + 1 - end = tag.rfind("") - return tag[start:end] if start > 0 and end > start else "" + # Determine which components the page needs + if source: + needed = components_needed(source, _COMPONENT_ENV) + else: + needed = None # all + + loaded_raw = request.headers.get("SX-Components", "") + loaded = set(loaded_raw.split(",")) if loaded_raw else set() - loaded = set(loaded_raw.split(",")) parts = [] for key, val in _COMPONENT_ENV.items(): if isinstance(val, Component): + comp_name = f"~{val.name}" + # Skip if not needed for this page + if needed is not None and comp_name not in needed and key not in needed: + continue # Skip components the client already has - if f"~{val.name}" in loaded or val.name in loaded: + if comp_name in loaded or val.name in loaded: continue # Reconstruct defcomp source param_strs = ["&key"] + list(val.params) @@ -530,7 +535,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() + comp_defs = components_for_request(source) if comp_defs: body = (f'\n{body}') @@ -641,11 +646,11 @@ def sx_page(ctx: dict, page_sx: str, *, renders everything client-side. CSS rules are scanned from the sx source and component defs, then injected as a