Implement isomorphic Phase 1: per-page component bundling
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m43s

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 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 11:19:10 +00:00
parent 1fe53c2032
commit 2866bcbfc3
5 changed files with 386 additions and 36 deletions

View File

@@ -198,6 +198,10 @@ def register_components(sx_source: str) -> None:
all_classes = scan_classes_from_sx(sx_source)
val.css_classes = set(all_classes)
# Recompute transitive deps for all components (cheap — just AST walking)
from .deps import compute_all_deps
compute_all_deps(_COMPONENT_ENV)
_compute_component_hash()
@@ -322,6 +326,71 @@ def client_components_tag(*names: str) -> str:
return f'<script type="text/sx" data-components>{source}</script>'
def components_for_page(page_sx: str) -> tuple[str, str]:
"""Return (component_defs_source, page_hash) for a page.
Scans *page_sx* for component references, computes the transitive
closure, and returns only the definitions needed for this page.
The hash is computed from the page-specific bundle for caching.
"""
from .deps import components_needed
from .parser import serialize
needed = components_needed(page_sx, _COMPONENT_ENV)
if not needed:
return "", ""
# Also include macros — they're needed for client-side expansion
parts = []
for key, val in _COMPONENT_ENV.items():
if isinstance(val, Component):
if f"~{val.name}" in needed or key in needed:
param_strs = ["&key"] + list(val.params)
if val.has_children:
param_strs.extend(["&rest", "children"])
params_sx = "(" + " ".join(param_strs) + ")"
body_sx = serialize(val.body, pretty=True)
parts.append(f"(defcomp ~{val.name} {params_sx} {body_sx})")
elif isinstance(val, Macro):
# Include macros that are referenced in needed components' bodies
# For now, include all macros (they're small and often shared)
param_strs = list(val.params)
if val.rest_param:
param_strs.extend(["&rest", val.rest_param])
params_sx = "(" + " ".join(param_strs) + ")"
body_sx = serialize(val.body, pretty=True)
parts.append(f"(defmacro {val.name} {params_sx} {body_sx})")
if not parts:
return "", ""
source = "\n".join(parts)
digest = hashlib.sha256(source.encode()).hexdigest()[:12]
return source, digest
def css_classes_for_page(page_sx: str) -> set[str]:
"""Return CSS classes needed for a page's component bundle + page source.
Instead of unioning ALL component CSS classes, only includes classes
from components the page actually uses.
"""
from .deps import components_needed
from .css_registry import scan_classes_from_sx
needed = components_needed(page_sx, _COMPONENT_ENV)
classes: set[str] = set()
for key, val in _COMPONENT_ENV.items():
if isinstance(val, Component):
if (f"~{val.name}" in needed or key in needed) and val.css_classes:
classes.update(val.css_classes)
# Page sx is unique per request — scan it
classes.update(scan_classes_from_sx(page_sx))
return classes
def sx_css_all() -> str:
"""Return all CSS rules (preamble + utilities) for Jinja fallback pages."""
from .css_registry import get_all_css