Cache sx component definitions in localStorage across page loads
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m30s
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:
@@ -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");
|
||||
|
||||
@@ -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("&", "&")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user