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