Cache sx component definitions in localStorage across page loads
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m30s

Server computes SHA-256 hash of all component source at startup.
Client signals its cached hash via cookie (sx-comp-hash). On full
page load: cookie match → server sends empty script tag with just
the hash; mismatch → sends full source. Client loads from
localStorage on hit, parses inline + caches on miss.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 00:57:53 +00:00
parent 4ede0368dc
commit 5436dfe76c
3 changed files with 122 additions and 13 deletions

View File

@@ -1342,9 +1342,56 @@
var text = s.textContent;
if (!text || !text.trim()) continue;
// data-components: load as component definitions
// data-components: load as component definitions (with localStorage caching)
if (s.hasAttribute("data-components")) {
Sx.loadComponents(text);
var hash = s.getAttribute("data-hash");
if (hash) {
var hasInline = text && text.trim();
try {
var cachedHash = localStorage.getItem("sx-components-hash");
if (cachedHash === hash) {
// Cache hit
if (hasInline) {
// Server sent full source (cookie was missing/stale) — update cache
localStorage.setItem("sx-components-src", text);
Sx.loadComponents(text);
} else {
// Server omitted source — load from cache
var cached = localStorage.getItem("sx-components-src");
if (cached) {
Sx.loadComponents(cached);
} else {
// Cache entry missing — clear cookie and reload to get full source
_clearSxCompCookie();
location.reload();
return;
}
}
} else {
// Cache miss — hash mismatch
if (hasInline) {
// Server sent full source — parse and cache
localStorage.setItem("sx-components-hash", hash);
localStorage.setItem("sx-components-src", text);
Sx.loadComponents(text);
} else {
// Server omitted source but our cache is stale — clear and reload
localStorage.removeItem("sx-components-hash");
localStorage.removeItem("sx-components-src");
_clearSxCompCookie();
location.reload();
return;
}
}
} catch (e) {
// localStorage unavailable — fall back to inline
if (hasInline) Sx.loadComponents(text);
}
_setSxCompCookie(hash);
} else {
// Legacy: no hash attribute — just load inline
if (text && text.trim()) Sx.loadComponents(text);
}
continue;
}
@@ -2396,6 +2443,18 @@
return text;
}
// ---------------------------------------------------------------------------
// sx-comp-hash cookie helpers (component caching)
// ---------------------------------------------------------------------------
function _setSxCompCookie(hash) {
document.cookie = "sx-comp-hash=" + hash + ";path=/;max-age=31536000;SameSite=Lax";
}
function _clearSxCompCookie() {
document.cookie = "sx-comp-hash=;path=/;max-age=0;SameSite=Lax";
}
if (typeof document !== "undefined") {
var init = function () {
console.log("[sx.js] v" + Sx.VERSION + " init");

View File

@@ -483,7 +483,7 @@ details.group{{overflow:hidden}}details.group>summary{{list-style:none}}details.
</style>
</head>
<body class="bg-stone-50 text-stone-900">
<script type="text/sx" data-components>{component_defs}</script>
<script type="text/sx" data-components data-hash="{component_hash}">{component_defs}</script>
<script type="text/sx" data-mount="body">{page_sx}</script>
<script src="{asset_url}/scripts/sx.js?v={sx_js_hash}"></script>
<script src="{asset_url}/scripts/body.js?v={body_js_hash}"></script>
@@ -499,19 +499,26 @@ 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 <style> block.
"""
from .jinja_bridge import client_components_tag, _COMPONENT_ENV
from .jinja_bridge import client_components_tag, _COMPONENT_ENV, get_component_hash
from .css_registry import scan_classes_from_sx, lookup_rules, get_preamble, registry_loaded, store_css_hash
from .types import Component
components_tag = client_components_tag()
# Extract just the inner source from the <script> tag
component_defs = ""
if components_tag:
# Strip <script type="text/sx" data-components>...</script>
start = components_tag.find(">") + 1
end = components_tag.rfind("</script>")
if start > 0 and end > start:
component_defs = components_tag[start:end]
component_hash = get_component_hash()
# Check if client already has this version cached (via cookie)
client_hash = _get_sx_comp_cookie()
if client_hash and client_hash == component_hash:
# Client has current components cached — send empty source
component_defs = ""
else:
components_tag = client_components_tag()
# Extract just the inner source from the <script> tag
component_defs = ""
if components_tag:
start = components_tag.find(">") + 1
end = components_tag.rfind("</script>")
if start > 0 and end > start:
component_defs = components_tag[start:end]
# Scan for CSS classes — use pre-computed sets for components, scan page sx at request time
sx_css = ""
@@ -543,6 +550,7 @@ def sx_page(ctx: dict, page_sx: str, *,
asset_url=asset_url,
meta_html=meta_html,
csrf=_html_escape(csrf),
component_hash=component_hash,
component_defs=component_defs,
page_sx=page_sx,
sx_css=sx_css,
@@ -575,6 +583,15 @@ def _get_csrf_token() -> str:
return ""
def _get_sx_comp_cookie() -> str:
"""Read the sx-comp-hash cookie from the current request."""
try:
from quart import request
return request.cookies.get("sx-comp-hash", "")
except Exception:
return ""
def _html_escape(s: str) -> str:
"""Minimal HTML escaping for attribute values."""
return (s.replace("&", "&amp;")

View File

@@ -21,6 +21,7 @@ Setup::
from __future__ import annotations
import glob
import hashlib
import os
from typing import Any
@@ -37,12 +38,42 @@ from .html import render as html_render, _render_component
# definition files or calling register_components().
_COMPONENT_ENV: dict[str, Any] = {}
# SHA-256 hash (12 hex chars) of all component definitions — used for
# client-side localStorage caching.
_COMPONENT_HASH: str = ""
def get_component_env() -> dict[str, Any]:
"""Return the shared component environment."""
return _COMPONENT_ENV
def get_component_hash() -> str:
"""Return the current component definitions hash."""
return _COMPONENT_HASH
def _compute_component_hash() -> None:
"""Recompute _COMPONENT_HASH from all registered Component definitions."""
global _COMPONENT_HASH
from .parser import serialize
parts = []
for key in sorted(_COMPONENT_ENV):
val = _COMPONENT_ENV[key]
if isinstance(val, Component):
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)
parts.append(f"(defcomp ~{val.name} {params_sx} {body_sx})")
if parts:
digest = hashlib.sha256("\n".join(parts).encode()).hexdigest()[:12]
_COMPONENT_HASH = digest
else:
_COMPONENT_HASH = ""
def load_sx_dir(directory: str) -> None:
"""Load all .sx files from a directory and register components."""
for filepath in sorted(
@@ -132,6 +163,8 @@ def register_components(sx_source: str) -> None:
all_classes = scan_classes_from_sx(sx_source)
val.css_classes = set(all_classes)
_compute_component_hash()
# ---------------------------------------------------------------------------
# sx() — render s-expression from Jinja template