Phase 4 complete: client data cache + plan update
- Add page data cache in orchestration.sx (30s TTL, keyed by page-name+params) - Cache hit path: sx:route client+cache (instant render, no fetch) - Cache miss path: sx:route client+data (fetch, cache, render) - Fix HTMX response dep computation to include :data pages - Update isomorphic-sx-plan.md: Phases 1-4 marked done with details, reorder remaining phases (continuations→Phase 5, suspense→Phase 6, optimistic updates→Phase 7) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -20,136 +20,147 @@ The key insight: **s-expressions can partially unfold on the server after IO, th
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 1: Component Distribution & Dependency Analysis
|
### Phase 1: Component Distribution & Dependency Analysis — DONE
|
||||||
|
|
||||||
**What it enables:** Per-page component bundles instead of sending every definition to every page. Smaller payloads, faster boot, better cache hit rates.
|
**What it enables:** Per-page component bundles instead of sending every definition to every page. Smaller payloads, faster boot, better cache hit rates.
|
||||||
|
|
||||||
**The problem:** `client_components_tag()` in `shared/sx/jinja_bridge.py` serializes ALL entries in `_COMPONENT_ENV`. The `sx_page()` template sends everything or nothing based on a single global hash. No mechanism determines which components a page actually needs.
|
**Implemented:**
|
||||||
|
|
||||||
**Approach:**
|
1. **Transitive closure analyzer** — `shared/sx/deps.py` (now `shared/sx/ref/deps.sx`, spec-level)
|
||||||
|
- Walk component body AST, collect all `~name` refs
|
||||||
1. **Transitive closure analyzer** — new module `shared/sx/deps.py`
|
|
||||||
- Walk `Component.body` AST, collect all `Symbol` refs starting with `~`
|
|
||||||
- Recursively follow into their bodies
|
- Recursively follow into their bodies
|
||||||
- Handle control forms (`if`/`when`/`cond`/`case`) — include ALL branches
|
- Handle control forms (`if`/`when`/`cond`/`case`) — include ALL branches
|
||||||
- Handle macros — expand during walk using limited eval
|
- `components_needed(source, env) -> set[str]`
|
||||||
- Function: `transitive_deps(name: str, env: dict) -> set[str]`
|
|
||||||
- Cache result on `Component` object (invalidate on hot-reload)
|
|
||||||
|
|
||||||
2. **Runtime component scanning** — after `_aser` serializes page content, scan the SX string for `(~name` patterns (parallel to existing `scan_classes_from_sx` for CSS). Then compute transitive closure to get sub-components.
|
2. **IO reference analysis** — `deps.sx` also tracks IO primitive usage
|
||||||
|
- `scan-io-refs` / `transitive-io-refs` / `component-pure?`
|
||||||
|
- Used by Phase 2 for automatic server/client boundary
|
||||||
|
|
||||||
3. **Per-page component block** in `sx_page()` — replace all-or-nothing with page-specific bundle. Hash changes per page, localStorage cache keyed by route pattern.
|
3. **Per-page component block** — `_build_pages_sx()` in `helpers.py`
|
||||||
|
- Each page entry includes `:deps` list of required components
|
||||||
|
- Client page registry carries dep info for prefetching
|
||||||
|
|
||||||
4. **SX partial responses** — `components_for_request()` already diffs against `SX-Components` header. Enhance with transitive closure so only truly needed missing components are sent.
|
4. **SX partial responses** — `components_for_request()` diffs against `SX-Components` header, sends only missing components
|
||||||
|
|
||||||
**Files:**
|
**Files:** `shared/sx/ref/deps.sx`, `shared/sx/deps.py`, `shared/sx/helpers.py`, `shared/sx/jinja_bridge.py`
|
||||||
- New: `shared/sx/deps.py` — dependency analysis
|
|
||||||
- `shared/sx/jinja_bridge.py` — per-page bundle generation, cache deps on Component
|
|
||||||
- `shared/sx/helpers.py` — modify `sx_page()` and `sx_response()` for page-specific bundles
|
|
||||||
- `shared/sx/types.py` — add `deps: set[str]` to Component
|
|
||||||
- `shared/sx/ref/boot.sx` — per-page component caching alongside global cache
|
|
||||||
|
|
||||||
**Verification:**
|
|
||||||
- Page using 5/50 components → `data-components` block contains only those 5 + transitive deps
|
|
||||||
- No "Unknown component" errors after bundle reduction
|
|
||||||
- Payload size reduction measurable
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 2: Smart Server/Client Boundary
|
### Phase 2: Smart Server/Client Boundary — DONE
|
||||||
|
|
||||||
**What it enables:** Formalized partial evaluation model. Server evaluates IO, serializes pure subtrees. The system automatically knows "this component needs server data" vs "this component is pure and can render anywhere."
|
**What it enables:** Formalized partial evaluation model. Server evaluates IO, serializes pure subtrees. The system automatically knows "this component needs server data" vs "this component is pure and can render anywhere."
|
||||||
|
|
||||||
**Current mechanism:** `_aser` in `async_eval.py` already does partial evaluation — IO primitives are awaited and substituted, HTML tags and component calls serialize as SX. The `_expand_components` context var controls expansion. But this is a global toggle, not per-component.
|
**Implemented:**
|
||||||
|
|
||||||
**Approach:**
|
1. **Automatic IO detection** — `deps.sx` walks component bodies for IO primitive refs
|
||||||
|
- `compute-all-io-refs` computes transitive IO analysis for all components
|
||||||
|
- `component-pure?` returns true if no IO refs transitively
|
||||||
|
|
||||||
1. **Automatic IO detection** — extend Phase 1 AST walker to check for references to `IO_PRIMITIVES` names (`frag`, `query`, `service`, `current-user`, etc.)
|
2. **Selective expansion** — `_aser` expands known components server-side via `_aser_component`
|
||||||
- `has_io_deps(name: str, env: dict) -> bool`
|
- IO-dependent components expand server-side (IO must resolve)
|
||||||
- Computed at registration time, cached on Component
|
- Unknown components serialize for client rendering
|
||||||
|
- `_expand_components` context var controls override
|
||||||
|
|
||||||
2. **Component metadata** — enrich Component with analysis results:
|
3. **Component metadata** — computed at registration, cached on Component objects
|
||||||
```python
|
|
||||||
ComponentMeta:
|
|
||||||
deps: set[str] # transitive component deps (Phase 1)
|
|
||||||
io_refs: set[str] # IO primitive names referenced
|
|
||||||
is_pure: bool # True if io_refs empty (transitively)
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Selective expansion** — refine `_aser` (line ~1335): instead of checking a global `_expand_components` flag, check the component's `is_pure` metadata:
|
**Files:** `shared/sx/ref/deps.sx`, `shared/sx/async_eval.py`, `shared/sx/jinja_bridge.py`
|
||||||
- IO-dependent → expand server-side (IO must resolve)
|
|
||||||
- Pure → serialize for client (let client render)
|
|
||||||
- Explicit override: `:server true` on defcomp forces server expansion
|
|
||||||
|
|
||||||
4. **Data manifest** for pages — `PageDef` produces a declaration of what IO the page needs, enabling Phase 3 (client can prefetch data) and Phase 5 (streaming).
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- `shared/sx/deps.py` — add IO analysis
|
|
||||||
- `shared/sx/types.py` — add metadata fields to Component
|
|
||||||
- `shared/sx/async_eval.py` — refine `_aser` component expansion logic
|
|
||||||
- `shared/sx/jinja_bridge.py` — compute IO metadata at registration
|
|
||||||
- `shared/sx/pages.py` — data manifest on PageDef
|
|
||||||
|
|
||||||
**Verification:**
|
|
||||||
- Components calling `(query ...)` classified IO-dependent; pure components classified pure
|
|
||||||
- Existing pages produce identical output (regression)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 3: Client-Side Routing (SPA Mode)
|
### Phase 3: Client-Side Routing (SPA Mode) — DONE
|
||||||
|
|
||||||
**What it enables:** After initial page load, client resolves routes locally using cached components + data. Only hits server for fresh data or unknown routes. Like Next.js client-side navigation.
|
**What it enables:** After initial page load, client resolves routes locally using cached components. Only hits server for fresh data or unknown routes.
|
||||||
|
|
||||||
**Current mechanism:** All routing is server-side via `defpage` → Quart routes. Client navigates via `sx-boost` links doing `sx-get` + morphing. Every navigation = server roundtrip.
|
**Implemented:**
|
||||||
|
|
||||||
**Approach:**
|
1. **Client-side page registry** — `_build_pages_sx()` serializes defpage routing info
|
||||||
|
- `<script type="text/sx-pages">` with name, path, auth, content, deps, closure, has-data
|
||||||
|
- Processed by `boot.sx` → `_page-routes` list
|
||||||
|
|
||||||
1. **Client-side page registry** — serialize defpage routing info to client as `<script type="text/sx-pages">`:
|
2. **Client route matcher** — `shared/sx/ref/router.sx`
|
||||||
```json
|
- `parse-route-pattern` converts Flask-style `/docs/<slug>` to matchers
|
||||||
{"docs-page": {"path": "/docs/:slug", "auth": "public",
|
- `find-matching-route` matches URL against registered routes
|
||||||
"content": "(case slug ...)", "data": null}}
|
- `match-route-segments` handles literal and param segments
|
||||||
```
|
|
||||||
Pure pages (no `:data`) can be evaluated entirely client-side.
|
|
||||||
|
|
||||||
2. **Client route matcher** — new spec file `shared/sx/ref/router.sx`:
|
3. **Client-side route intercept** — `orchestration.sx`
|
||||||
- Convert `/docs/<slug>` patterns to matchers
|
- `try-client-route` — match URL, eval content locally, swap DOM
|
||||||
- On boost-link click: match URL → if found and pure, evaluate locally
|
- `bind-client-route-link` — intercept boost link clicks
|
||||||
- If IO needed: fetch data from server, evaluate content locally
|
- Pure pages render immediately, no server roundtrip
|
||||||
- No match: fall through to standard fetch (existing behavior)
|
- Falls through to server fetch on miss
|
||||||
|
|
||||||
3. **Data endpoint** — `GET /internal/page-data/<page-name>?<params>` returns JSON with evaluated `:data` expression. Reuses `execute_page()` logic but stops after `:data` step.
|
4. **Integration with engine** — boost link clicks try client route first, fall back to standard fetch
|
||||||
|
|
||||||
4. **Layout caching** — layouts depend on auth/fragments, so cache current layout and reuse across navigations. `SX-Layout-Hash` header tracks staleness.
|
**Files:** `shared/sx/ref/router.sx`, `shared/sx/ref/boot.sx`, `shared/sx/ref/orchestration.sx`, `shared/sx/helpers.py`, `shared/sx/pages.py`
|
||||||
|
|
||||||
5. **Integration with orchestration.sx** — intercept `bind-boost-link` to try client-side resolution first.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- `shared/sx/pages.py` — `serialize_for_client()`, data-only execution path
|
|
||||||
- `shared/sx/helpers.py` — include page registry in `sx_page()`
|
|
||||||
- New: `shared/sx/ref/router.sx` — client-side route matching
|
|
||||||
- `shared/sx/ref/boot.sx` — process `<script type="text/sx-pages">`
|
|
||||||
- `shared/sx/ref/orchestration.sx` — client-side route intercept
|
|
||||||
- Service blueprints — `/internal/page-data/` endpoint
|
|
||||||
|
|
||||||
**Depends on:** Phase 1 (client knows which components each page needs), Phase 2 (which pages are pure vs IO)
|
|
||||||
|
|
||||||
**Verification:**
|
|
||||||
- Pure page navigation: zero server requests
|
|
||||||
- IO page navigation: exactly one data request (not full page fetch)
|
|
||||||
- Browser back/forward works with client-resolved routes
|
|
||||||
- Disabling client registry → identical behavior to current
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 4: Client Async & IO Bridge
|
### Phase 4: Client Async & IO Bridge — DONE
|
||||||
|
|
||||||
**What it enables:** Client evaluates IO primitives by mapping them to server REST calls. Same SX code, different transport. `(query "market" "products" :ids "1,2,3")` on server → DB; on client → `fetch("/internal/data/products?ids=1,2,3")`.
|
**What it enables:** Client fetches server-evaluated data and renders `:data` pages locally. Data cached to avoid redundant fetches on back/forward navigation.
|
||||||
|
|
||||||
|
**The approach:** Separate IO from rendering. Server evaluates `:data` expression (async, with DB/service access), serializes result as SX wire format. Client fetches this pre-evaluated data, parses it, merges into env, renders pure `:content` client-side. No continuations needed — all IO happens server-side.
|
||||||
|
|
||||||
|
**Implemented:**
|
||||||
|
|
||||||
|
1. **Abstract `resolve-page-data`** — spec-level primitive in `orchestration.sx`
|
||||||
|
- `(resolve-page-data name params callback)` — platform decides transport
|
||||||
|
- Spec says "I need data for this page"; platform provides concrete implementation
|
||||||
|
- Browser platform: HTTP fetch to `/sx/data/` endpoint
|
||||||
|
|
||||||
|
2. **Server data endpoint** — `pages.py`
|
||||||
|
- `evaluate_page_data()` — evaluates `:data` expression, kebab-cases dict keys, serializes as SX
|
||||||
|
- `auto_mount_page_data()` — mounts `GET /sx/data/<page_name>` endpoint
|
||||||
|
- Per-page auth enforcement via `_check_page_auth()`
|
||||||
|
- Response content type: `text/sx; charset=utf-8`
|
||||||
|
|
||||||
|
3. **Client-side data rendering** — `orchestration.sx`
|
||||||
|
- `try-client-route` handles `:data` pages: fetch data → parse SX → merge into env → render content
|
||||||
|
- Console log: `sx:route client+data <pathname>` confirms client-side rendering
|
||||||
|
- Component deps computed for `:data` pages too (not just pure pages)
|
||||||
|
|
||||||
|
4. **Client data cache** — `orchestration.sx`
|
||||||
|
- `_page-data-cache` dict keyed by `page-name:param=value`
|
||||||
|
- 30s TTL (configurable via `_page-data-cache-ttl`)
|
||||||
|
- Cache hit: `sx:route client+cache <pathname>` — renders instantly
|
||||||
|
- Cache miss: fetches, caches, renders
|
||||||
|
- Stale entries evicted on next access
|
||||||
|
|
||||||
|
5. **Test page** — `sx/sx/data-test.sx`
|
||||||
|
- Exercises full data pipeline: server time, pipeline steps, phase/transport metadata
|
||||||
|
- Navigate from another page → console shows `sx:route client+data`
|
||||||
|
- Navigate back → console shows `sx:route client+cache`
|
||||||
|
|
||||||
|
6. **Unit tests** — `shared/sx/tests/test_page_data.py` (20 tests)
|
||||||
|
- Serialize roundtrip for all data types
|
||||||
|
- Kebab-case key conversion
|
||||||
|
- Component deps for `:data` pages
|
||||||
|
- Full pipeline simulation (serialize → parse → merge → eval)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `shared/sx/ref/orchestration.sx` — `resolve-page-data` spec, data cache
|
||||||
|
- `shared/sx/ref/bootstrap_js.py` — platform `resolvePageData` implementation
|
||||||
|
- `shared/sx/pages.py` — `evaluate_page_data()`, `auto_mount_page_data()`
|
||||||
|
- `shared/sx/helpers.py` — deps for `:data` pages
|
||||||
|
- `sx/sx/data-test.sx` — test component
|
||||||
|
- `sx/sxc/pages/docs.sx` — test page defpage
|
||||||
|
- `sx/sxc/pages/helpers.py` — `data-test-data` helper
|
||||||
|
- `sx/sx/boundary.sx` — helper declaration
|
||||||
|
- `shared/sx/tests/test_page_data.py` — unit tests
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5: Async Continuations & Inline IO
|
||||||
|
|
||||||
|
**What it enables:** Components call IO primitives directly in their body (e.g. `(query ...)`). The evaluator suspends mid-evaluation, fetches data, resumes. Same component source works on both server (Python async/await) and client (continuation-based suspension).
|
||||||
|
|
||||||
|
**The problem:** The existing `shift/reset` continuations extension is synchronous (throw/catch). Client-side IO via `fetch()` returns a Promise, and you can't throw-catch across an async boundary. The evaluator needs Promise-aware continuations.
|
||||||
|
|
||||||
**Approach:**
|
**Approach:**
|
||||||
|
|
||||||
1. **Async client evaluator** — two possible mechanisms:
|
1. **Async-aware shift/reset** — extend the continuations extension:
|
||||||
- **Promise-based:** `evalExpr` returns value or Promise; rendering awaits
|
- `sfShift` captures the continuation and returns a Promise
|
||||||
- **Continuation-based:** use existing `shift/reset` to suspend on IO, resume when data arrives (architecturally cleaner, leverages existing spec)
|
- `sfReset` awaits Promise results in the trampoline
|
||||||
|
- Continuation resume feeds the fetched value back into the evaluation
|
||||||
|
|
||||||
2. **IO primitive bridge** — register async IO primitives in client `PRIMITIVES`:
|
2. **IO primitive bridge** — register async IO primitives in client `PRIMITIVES`:
|
||||||
- `query` → fetch to `/internal/data/`
|
- `query` → fetch to `/internal/data/`
|
||||||
@@ -157,27 +168,22 @@ The key insight: **s-expressions can partially unfold on the server after IO, th
|
|||||||
- `frag` → fetch fragment HTML
|
- `frag` → fetch fragment HTML
|
||||||
- `current-user` → cached from initial page load
|
- `current-user` → cached from initial page load
|
||||||
|
|
||||||
3. **Client data cache** — keyed by `(service, query, params-hash)`, configurable TTL, server can invalidate via `SX-Invalidate` header.
|
3. **CPS transform option** — alternative to Promise-aware shift/reset:
|
||||||
|
- Transform the evaluator to continuation-passing style
|
||||||
|
- Every eval step takes a continuation argument
|
||||||
|
- IO primitives call the continuation after fetch resolves
|
||||||
|
- Architecturally cleaner but requires deeper changes
|
||||||
|
|
||||||
4. **Optimistic updates** — extend existing `apply-optimistic`/`revert-optimistic` in `engine.sx` from DOM-level to data-level.
|
**Depends on:** Phase 4 (data endpoint infrastructure)
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- `shared/sx/ref/eval.sx` — async dispatch path (or new `async-eval.sx`)
|
|
||||||
- New: `shared/sx/ref/io-bridge.sx` — client IO implementations
|
|
||||||
- `shared/sx/ref/boot.sx` — register IO bridge at init
|
|
||||||
- `shared/sx/ref/bootstrap_js.py` — emit async-aware code
|
|
||||||
- `/internal/data/` endpoints — ensure client-accessible (CORS, auth)
|
|
||||||
|
|
||||||
**Depends on:** Phase 2 (IO affinity), Phase 3 (routing for when to trigger IO)
|
|
||||||
|
|
||||||
**Verification:**
|
**Verification:**
|
||||||
- Client `(query ...)` returns identical data to server-side
|
- Component calling `(query ...)` on client fetches data and renders
|
||||||
- Data cache prevents redundant fetches
|
- Same component source → identical output on server and client
|
||||||
- Same component source → identical output on either side
|
- Suspension visible: placeholder → resolved content
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 5: Streaming & Suspense
|
### Phase 6: Streaming & Suspense
|
||||||
|
|
||||||
**What it enables:** Server streams partially-evaluated SX as IO resolves. Client renders available subtrees immediately, fills in suspended parts. Like React Suspense but built on delimited continuations.
|
**What it enables:** Server streams partially-evaluated SX as IO resolves. Client renders available subtrees immediately, fills in suspended parts. Like React Suspense but built on delimited continuations.
|
||||||
|
|
||||||
@@ -203,11 +209,11 @@ The key insight: **s-expressions can partially unfold on the server after IO, th
|
|||||||
- New: `shared/sx/ref/suspense.sx` — client suspension rendering
|
- New: `shared/sx/ref/suspense.sx` — client suspension rendering
|
||||||
- `shared/sx/ref/boot.sx` — handle resolution scripts
|
- `shared/sx/ref/boot.sx` — handle resolution scripts
|
||||||
|
|
||||||
**Depends on:** Phase 4 (client async for filling suspended subtrees), Phase 2 (IO analysis for priority)
|
**Depends on:** Phase 5 (async continuations for filling suspended subtrees), Phase 2 (IO analysis for priority)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 6: Full Isomorphism
|
### Phase 7: Full Isomorphism
|
||||||
|
|
||||||
**What it enables:** Same SX code runs on either side. Runtime chooses optimal split. Offline-first with cached data + client eval.
|
**What it enables:** Same SX code runs on either side. Runtime chooses optimal split. Offline-first with cached data + client eval.
|
||||||
|
|
||||||
@@ -226,11 +232,16 @@ The key insight: **s-expressions can partially unfold on the server after IO, th
|
|||||||
```
|
```
|
||||||
Default: auto (runtime decides from IO analysis).
|
Default: auto (runtime decides from IO analysis).
|
||||||
|
|
||||||
3. **Offline data layer** — Service Worker intercepts `/internal/data/` requests, serves from IndexedDB when offline, syncs when back online.
|
3. **Optimistic data updates** — extend existing `apply-optimistic`/`revert-optimistic` in `engine.sx` from DOM-level to data-level:
|
||||||
|
- Client updates cached data optimistically (e.g., like button increments count)
|
||||||
|
- Sends mutation to server
|
||||||
|
- If server confirms, keep; if rejects, revert cached data and re-render
|
||||||
|
|
||||||
4. **Isomorphic testing** — evaluate same component on Python and JS, compare output. Extends existing `test_sx_ref.py` cross-evaluator comparison.
|
4. **Offline data layer** — Service Worker intercepts `/internal/data/` requests, serves from IndexedDB when offline, syncs when back online.
|
||||||
|
|
||||||
5. **Universal page descriptor** — `defpage` is portable: server executes via `execute_page()`, client executes via route match → fetch data → eval content → render DOM. Same descriptor, different execution environment.
|
5. **Isomorphic testing** — evaluate same component on Python and JS, compare output. Extends existing `test_sx_ref.py` cross-evaluator comparison.
|
||||||
|
|
||||||
|
6. **Universal page descriptor** — `defpage` is portable: server executes via `execute_page()`, client executes via route match → fetch data → eval content → render DOM. Same descriptor, different execution environment.
|
||||||
|
|
||||||
**Depends on:** All previous phases.
|
**Depends on:** All previous phases.
|
||||||
|
|
||||||
@@ -259,15 +270,15 @@ All new behavior specified in `.sx` files under `shared/sx/ref/` before implemen
|
|||||||
|
|
||||||
| File | Role | Phases |
|
| File | Role | Phases |
|
||||||
|------|------|--------|
|
|------|------|--------|
|
||||||
| `shared/sx/async_eval.py` | Core evaluator, `_aser`, server/client boundary | 2, 5 |
|
| `shared/sx/async_eval.py` | Core evaluator, `_aser`, server/client boundary | 2, 6 |
|
||||||
| `shared/sx/helpers.py` | `sx_page()`, `sx_response()`, output pipeline | 1, 3 |
|
| `shared/sx/helpers.py` | `sx_page()`, `sx_response()`, output pipeline | 1, 3, 4 |
|
||||||
| `shared/sx/jinja_bridge.py` | `_COMPONENT_ENV`, component registry | 1, 2 |
|
| `shared/sx/jinja_bridge.py` | `_COMPONENT_ENV`, component registry | 1, 2 |
|
||||||
| `shared/sx/pages.py` | `defpage`, `execute_page()`, page lifecycle | 2, 3 |
|
| `shared/sx/pages.py` | `defpage`, `execute_page()`, page lifecycle, data endpoint | 2, 3, 4 |
|
||||||
| `shared/sx/ref/boot.sx` | Client boot, component caching | 1, 3, 4 |
|
| `shared/sx/ref/boot.sx` | Client boot, component caching, page registry | 1, 3, 4 |
|
||||||
| `shared/sx/ref/orchestration.sx` | Client fetch/swap/morph | 3, 4 |
|
| `shared/sx/ref/orchestration.sx` | Client fetch/swap/morph, routing, data cache | 3, 4, 5 |
|
||||||
| `shared/sx/ref/eval.sx` | Evaluator spec | 4 |
|
| `shared/sx/ref/eval.sx` | Evaluator spec | 5 |
|
||||||
| `shared/sx/ref/engine.sx` | Morph, swaps, triggers | 3 |
|
| `shared/sx/ref/engine.sx` | Morph, swaps, triggers | 3 |
|
||||||
| New: `shared/sx/deps.py` | Dependency analysis | 1, 2 |
|
| `shared/sx/ref/deps.sx` | Dependency + IO analysis (spec) | 1, 2 |
|
||||||
| New: `shared/sx/ref/router.sx` | Client-side routing | 3 |
|
| `shared/sx/ref/router.sx` | Client-side route matching | 3 |
|
||||||
| New: `shared/sx/ref/io-bridge.sx` | Client IO primitives | 4 |
|
| `shared/sx/ref/bootstrap_js.py` | JS bootstrapper, platform implementations | 4, 5 |
|
||||||
| New: `shared/sx/ref/suspense.sx` | Streaming/suspension | 5 |
|
| New: `shared/sx/ref/suspense.sx` | Streaming/suspension | 6 |
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
|
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
|
||||||
var SX_VERSION = "2026-03-06T23:52:56Z";
|
var SX_VERSION = "2026-03-07T00:04:07Z";
|
||||||
|
|
||||||
function isNil(x) { return x === NIL || x === null || x === undefined; }
|
function isNil(x) { return x === NIL || x === null || x === undefined; }
|
||||||
function isSxTruthy(x) { return x !== false && !isNil(x); }
|
function isSxTruthy(x) { return x !== false && !isNil(x); }
|
||||||
@@ -1930,6 +1930,31 @@ return domAppendToHead(link); }, domQueryAll(container, "link[rel=\"stylesheet\"
|
|||||||
})()) : NIL); }, domQueryAll(container, "form"));
|
})()) : NIL); }, domQueryAll(container, "form"));
|
||||||
})(); };
|
})(); };
|
||||||
|
|
||||||
|
// _page-data-cache
|
||||||
|
var _pageDataCache = {};
|
||||||
|
|
||||||
|
// _page-data-cache-ttl
|
||||||
|
var _pageDataCacheTtl = 30000;
|
||||||
|
|
||||||
|
// page-data-cache-key
|
||||||
|
var pageDataCacheKey = function(pageName, params) { return (function() {
|
||||||
|
var base = pageName;
|
||||||
|
return (isSxTruthy(sxOr(isNil(params), isEmpty(keys(params)))) ? base : (function() {
|
||||||
|
var parts = [];
|
||||||
|
{ var _c = keys(params); for (var _i = 0; _i < _c.length; _i++) { var k = _c[_i]; parts.push((String(k) + String("=") + String(get(params, k)))); } }
|
||||||
|
return (String(base) + String(":") + String(join("&", parts)));
|
||||||
|
})());
|
||||||
|
})(); };
|
||||||
|
|
||||||
|
// page-data-cache-get
|
||||||
|
var pageDataCacheGet = function(cacheKey) { return (function() {
|
||||||
|
var entry = get(_pageDataCache, cacheKey);
|
||||||
|
return (isSxTruthy(isNil(entry)) ? NIL : (isSxTruthy(((nowMs() - get(entry, "ts")) > _pageDataCacheTtl)) ? (dictSet(_pageDataCache, cacheKey, NIL), NIL) : get(entry, "data")));
|
||||||
|
})(); };
|
||||||
|
|
||||||
|
// page-data-cache-set
|
||||||
|
var pageDataCacheSet = function(cacheKey, data) { return dictSet(_pageDataCache, cacheKey, {"data": data, "ts": nowMs()}); };
|
||||||
|
|
||||||
// swap-rendered-content
|
// swap-rendered-content
|
||||||
var swapRenderedContent = function(target, rendered, pathname) { return (domSetTextContent(target, ""), domAppend(target, rendered), hoistHeadElementsFull(target), processElements(target), sxHydrateElements(target), domDispatch(target, "sx:clientRoute", {["pathname"]: pathname}), logInfo((String("sx:route client ") + String(pathname)))); };
|
var swapRenderedContent = function(target, rendered, pathname) { return (domSetTextContent(target, ""), domAppend(target, rendered), hoistHeadElementsFull(target), processElements(target), sxHydrateElements(target), domDispatch(target, "sx:clientRoute", {["pathname"]: pathname}), logInfo((String("sx:route client ") + String(pathname)))); };
|
||||||
|
|
||||||
@@ -1946,11 +1971,20 @@ return domAppendToHead(link); }, domQueryAll(container, "link[rel=\"stylesheet\"
|
|||||||
var pageName = get(match, "name");
|
var pageName = get(match, "name");
|
||||||
return (isSxTruthy(sxOr(isNil(contentSrc), isEmpty(contentSrc))) ? (logWarn((String("sx:route no content for ") + String(pathname))), false) : (function() {
|
return (isSxTruthy(sxOr(isNil(contentSrc), isEmpty(contentSrc))) ? (logWarn((String("sx:route no content for ") + String(pathname))), false) : (function() {
|
||||||
var target = resolveRouteTarget(targetSel);
|
var target = resolveRouteTarget(targetSel);
|
||||||
return (isSxTruthy(isNil(target)) ? (logWarn((String("sx:route target not found: ") + String(targetSel))), false) : (isSxTruthy(get(match, "has-data")) ? (logInfo((String("sx:route client+data ") + String(pathname))), resolvePageData(pageName, params, function(data) { return (function() {
|
return (isSxTruthy(isNil(target)) ? (logWarn((String("sx:route target not found: ") + String(targetSel))), false) : (isSxTruthy(get(match, "has-data")) ? (function() {
|
||||||
|
var cacheKey = pageDataCacheKey(pageName, params);
|
||||||
|
var cached = pageDataCacheGet(cacheKey);
|
||||||
|
return (isSxTruthy(cached) ? (function() {
|
||||||
|
var env = merge(closure, params, cached);
|
||||||
|
var rendered = tryEvalContent(contentSrc, env);
|
||||||
|
return (isSxTruthy(isNil(rendered)) ? (logWarn((String("sx:route cached eval failed for ") + String(pathname))), false) : (logInfo((String("sx:route client+cache ") + String(pathname))), swapRenderedContent(target, rendered, pathname), true));
|
||||||
|
})() : (logInfo((String("sx:route client+data ") + String(pathname))), resolvePageData(pageName, params, function(data) { pageDataCacheSet(cacheKey, data);
|
||||||
|
return (function() {
|
||||||
var env = merge(closure, params, data);
|
var env = merge(closure, params, data);
|
||||||
var rendered = tryEvalContent(contentSrc, env);
|
var rendered = tryEvalContent(contentSrc, env);
|
||||||
return (isSxTruthy(isNil(rendered)) ? logWarn((String("sx:route data eval failed for ") + String(pathname))) : swapRenderedContent(target, rendered, pathname));
|
return (isSxTruthy(isNil(rendered)) ? logWarn((String("sx:route data eval failed for ") + String(pathname))) : swapRenderedContent(target, rendered, pathname));
|
||||||
})(); }), true) : (function() {
|
})(); }), true));
|
||||||
|
})() : (function() {
|
||||||
var env = merge(closure, params);
|
var env = merge(closure, params);
|
||||||
var rendered = tryEvalContent(contentSrc, env);
|
var rendered = tryEvalContent(contentSrc, env);
|
||||||
return (isSxTruthy(isNil(rendered)) ? (logInfo((String("sx:route server (eval failed) ") + String(pathname))), false) : (swapRenderedContent(target, rendered, pathname), true));
|
return (isSxTruthy(isNil(rendered)) ? (logInfo((String("sx:route server (eval failed) ") + String(pathname))), false) : (swapRenderedContent(target, rendered, pathname), true));
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ async def execute_page(
|
|||||||
# Compute content expression deps so the server sends component
|
# Compute content expression deps so the server sends component
|
||||||
# definitions the client needs for future client-side routing
|
# definitions the client needs for future client-side routing
|
||||||
extra_deps: set[str] | None = None
|
extra_deps: set[str] | None = None
|
||||||
if page_def.content_expr is not None and page_def.data_expr is None:
|
if page_def.content_expr is not None:
|
||||||
from .deps import components_needed
|
from .deps import components_needed
|
||||||
from .parser import serialize
|
from .parser import serialize
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -545,6 +545,50 @@
|
|||||||
(dom-query-all container "form")))))
|
(dom-query-all container "form")))))
|
||||||
|
|
||||||
|
|
||||||
|
;; --------------------------------------------------------------------------
|
||||||
|
;; Client-side routing — data cache
|
||||||
|
;; --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
;; Cache for page data resolved via resolve-page-data.
|
||||||
|
;; Keyed by "page-name:param1=val1¶m2=val2", value is {data, ts}.
|
||||||
|
;; Default TTL: 30s. Prevents redundant fetches on back/forward navigation.
|
||||||
|
|
||||||
|
(define _page-data-cache (dict))
|
||||||
|
(define _page-data-cache-ttl 30000) ;; 30 seconds in ms
|
||||||
|
|
||||||
|
(define page-data-cache-key
|
||||||
|
(fn (page-name params)
|
||||||
|
;; Build a cache key from page name + params.
|
||||||
|
;; Params are from route matching so order is deterministic.
|
||||||
|
(let ((base page-name))
|
||||||
|
(if (or (nil? params) (empty? (keys params)))
|
||||||
|
base
|
||||||
|
(let ((parts (list)))
|
||||||
|
(for-each
|
||||||
|
(fn (k)
|
||||||
|
(append! parts (str k "=" (get params k))))
|
||||||
|
(keys params))
|
||||||
|
(str base ":" (join "&" parts)))))))
|
||||||
|
|
||||||
|
(define page-data-cache-get
|
||||||
|
(fn (cache-key)
|
||||||
|
;; Return cached data if fresh, else nil.
|
||||||
|
(let ((entry (get _page-data-cache cache-key)))
|
||||||
|
(if (nil? entry)
|
||||||
|
nil
|
||||||
|
(if (> (- (now-ms) (get entry "ts")) _page-data-cache-ttl)
|
||||||
|
(do
|
||||||
|
(dict-set! _page-data-cache cache-key nil)
|
||||||
|
nil)
|
||||||
|
(get entry "data"))))))
|
||||||
|
|
||||||
|
(define page-data-cache-set
|
||||||
|
(fn (cache-key data)
|
||||||
|
;; Store data with current timestamp.
|
||||||
|
(dict-set! _page-data-cache cache-key
|
||||||
|
{"data" data "ts" (now-ms)})))
|
||||||
|
|
||||||
|
|
||||||
;; --------------------------------------------------------------------------
|
;; --------------------------------------------------------------------------
|
||||||
;; Client-side routing
|
;; Client-side routing
|
||||||
;; --------------------------------------------------------------------------
|
;; --------------------------------------------------------------------------
|
||||||
@@ -593,18 +637,31 @@
|
|||||||
(if (nil? target)
|
(if (nil? target)
|
||||||
(do (log-warn (str "sx:route target not found: " target-sel)) false)
|
(do (log-warn (str "sx:route target not found: " target-sel)) false)
|
||||||
(if (get match "has-data")
|
(if (get match "has-data")
|
||||||
;; Data page: resolve data asynchronously, then render client-side
|
;; Data page: check cache, else resolve asynchronously
|
||||||
(do
|
(let ((cache-key (page-data-cache-key page-name params))
|
||||||
(log-info (str "sx:route client+data " pathname))
|
(cached (page-data-cache-get cache-key)))
|
||||||
(resolve-page-data page-name params
|
(if cached
|
||||||
(fn (data)
|
;; Cache hit — render immediately
|
||||||
;; data is a dict — merge into env and render
|
(let ((env (merge closure params cached))
|
||||||
(let ((env (merge closure params data))
|
(rendered (try-eval-content content-src env)))
|
||||||
(rendered (try-eval-content content-src env)))
|
(if (nil? rendered)
|
||||||
(if (nil? rendered)
|
(do (log-warn (str "sx:route cached eval failed for " pathname)) false)
|
||||||
(log-warn (str "sx:route data eval failed for " pathname))
|
(do
|
||||||
(swap-rendered-content target rendered pathname)))))
|
(log-info (str "sx:route client+cache " pathname))
|
||||||
true)
|
(swap-rendered-content target rendered pathname)
|
||||||
|
true)))
|
||||||
|
;; Cache miss — fetch, cache, render
|
||||||
|
(do
|
||||||
|
(log-info (str "sx:route client+data " pathname))
|
||||||
|
(resolve-page-data page-name params
|
||||||
|
(fn (data)
|
||||||
|
(page-data-cache-set cache-key data)
|
||||||
|
(let ((env (merge closure params data))
|
||||||
|
(rendered (try-eval-content content-src env)))
|
||||||
|
(if (nil? rendered)
|
||||||
|
(log-warn (str "sx:route data eval failed for " pathname))
|
||||||
|
(swap-rendered-content target rendered pathname)))))
|
||||||
|
true)))
|
||||||
;; Pure page: render immediately
|
;; Pure page: render immediately
|
||||||
(let ((env (merge closure params))
|
(let ((env (merge closure params))
|
||||||
(rendered (try-eval-content content-src env)))
|
(rendered (try-eval-content content-src env)))
|
||||||
|
|||||||
Reference in New Issue
Block a user