From da28db97751f76a756bd922b28c4cd8752e1def0 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Sun, 2 Aug 2026 22:06:18 +0200 Subject: [PATCH] feat(runtime): support viewport-ahead reveals --- AGENTS.md | 2 +- REQUIREMENTS.md | 4 +- examples/html_examples/README.md | 2 +- examples/html_examples/templates/gallery.heml | 2 +- hemx-build/src/lib.rs | 26 +++++++++++- hemx-js/runtime/hemx.js | 42 +++++++++++++------ hemx-js/tests/runtime.rs | 3 ++ 7 files changed, 62 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 826e374..a9e26f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,7 +67,7 @@ Keep it stable. Prefer pointers to canonical sources over copied structure, file - The public component-reuse explanation lives in `docs/recipes/reusable-partials.md`; do not grow a client component framework to explain partial composition. - The stable public `.heml` authoring surface lives in `docs/hemplate-syntax.md`; Hemlate examples must use that real hemplate syntax, not Vue/Handlebars sketches. hemx-build consumes hemplate Surface facts and must not grow an independent `.heml` parser or CSS-path identity model. Plain CSS/SCSS owns appearance; generated class constants are ergonomic references, not a styling framework or behavior selector system. Generated resources, form/handle metadata, atoms, and event constants come from hemx-build facts, not hand-written app plumbing. Forms remain HTML-shaped, checked against user-authored Rust domain types, parsed through `FormValue`, and manipulated through generated form/control ids rather than selectors. Proc-macros stay local/side-effect-free while build.rs owns global codegen and hard build failures. No-op global codegen must preserve generated artifact timestamps so downstream Rust compilation remains fresh only when canonical output changes. req: boundary/001 req: boundary/002 req: boundary/003 req: boundary/004 req: surface/001 req: surface/002 req: surface/003 req: surface/004 req: surface/005 req: surface/006 req: surface/007 req: surface/008 req: surface/009 req: surface/010 req: codegen/001 req: codegen/003 req: codegen/004 req: codegen/005 req: codegen/006 req: form/001 req: form/004 req: form/007 req: form/008 req: form_effects/001 req: form_effects/002 req: form_effects/003 req: build/001 req: build/002 req: build/003 req: build/004 req: build/005 req: build/006 req: build/007 req: build/008 req: build/009 req: style/001 req: style/002 req: style/003 req: style/004 req: style/005 req: style/006 - Optional `.heml` editor overlays must share authority with `hemx-build` diagnostics and `docs/hemplate-syntax.md`; `hemx-lsp` owns editor protocol glue for diagnostics/completion/hover and derive-known template facts, while VS Code/Cursor/Neovim keep normal HTML/tree-sitter tooling. Simple `h-for` completion facts cover one Rust identifier bound directly to a `self` vector field; malformed bindings and non-vector fields must not fabricate locals. Do not create a second template language, selector model, formatter, Rust type system, or custom editor framework. Compiler diagnostics with directive/target metadata select that source attribute instead of line 0 column 0. Cross-file template/handler references visible to build validation must fail at `cargo check` with useful spans; global completeness checks stay component-scoped unless caught at mount/tests. req: diagnostics/004 req: diagnostics/005 req: diagnostics/006 req: diagnostics/007 req: diagnostics/008 req: diag/009 req: diag/010 req: invariant/004 req: invariant/006 req: check/001 req: check/003 -- JS runtime changes must preserve root-scoped lookup, delegated listeners, canonical hemx `EffectBatch` application, dynamic polling/revealed binding, fail-closed request handling, transactional/recoverable failure behavior, root-scoped error outlets, and tiny pending/failure/trigger-timing conventions without selectors, handler-name parsing, VDOM, expressions, or per-node listeners. Runtime `.d.ts` types are developer convenience only, not core tooling authority. req: invariant/002 req: runtime/001 req: runtime/002 req: runtime/003 req: runtime/005 req: runtime/006 req: runtime/007 req: failure/001 req: failure/002 req: failure/003 req: failure/004 req: failure/005 req: failure/006 req: convention/001 req: convention/002 req: convention/003 req: convention/004 req: convention/005 req: convention/006 req: convention/007 req: convention/008 req: convention/009 req: convention/010 req: convention/011 req: convention/012 req: convention/013 req: convention/014 req: convention/015 req: convention/016 req: convention/017 req: ts/001 +- JS runtime changes must preserve root-scoped lookup, delegated listeners, canonical hemx `EffectBatch` application, dynamic polling and viewport-aware revealed binding, fail-closed request handling, transactional/recoverable failure behavior, root-scoped error outlets, and tiny pending/failure/trigger-timing conventions without selectors, handler-name parsing, VDOM, expressions, or per-node listeners. Runtime `.d.ts` types are developer convenience only, not core tooling authority. req: invariant/002 req: runtime/001 req: runtime/002 req: runtime/003 req: runtime/005 req: runtime/006 req: runtime/007 req: failure/001 req: failure/002 req: failure/003 req: failure/004 req: failure/005 req: failure/006 req: convention/001 req: convention/002 req: convention/003 req: convention/004 req: convention/005 req: convention/006 req: convention/007 req: convention/008 req: convention/009 req: convention/010 req: convention/011 req: convention/012 req: convention/013 req: convention/014 req: convention/015 req: convention/016 req: convention/017 req: ts/001 - Opaque island JavaScript is a leaf adapter for high-frequency local behavior only; client-local handlers keep the server-handler shape while `hemx-wasm` owns concrete opt-in syntax. Use native events/generated helpers at the boundary and do not introduce a component runtime, client state graph, VDOM, selector interop, or second UI model. req: canonical_authoring/017 req: client_local/001 req: client_local/003 req: client_local/004 req: interop/001 req: interop/002 req: interop/003 req: interop/006 req: interop/007 req: interop/008 req: interop/009 req: interop/010 req: interop/011 req: interop/012 - Host capability adapters must stay at the `hemx-host` boundary: typed capabilities use fire/request/stream/schedule shapes; adapters may call host APIs and return host events, but they must not mutate DOM or own app/domain state. req: host/001 req: host/002 - Local/offline app behavior should be commands/events/projections; do not add `hemx-local`, stored DOM patches, stored `EffectBatch` truth, or a core client state graph without a proven reusable contract. Atoms are explicit addressable/bootstrap/sync resources, not the default state container or a reactive framework. Replay, reconciliation, export, and deletion rules stay explicit product decisions, and exemplars should show UI effects as app-state output. The local-first multiplayer kanban remains an advanced north-star integration milestone, not beginner/API surface scope. req: canonical_authoring/018 req: canonical_authoring/019 req: state/001 req: state/002 req: state/003 req: state/004 req: state/005 req: state/006 req: state/007 req: local/001 req: local/002 req: local/003 req: local/004 req: milestone/001 req: milestone/002 req: milestone/003 diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 05c7d84..3f1787e 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -1005,13 +1005,13 @@ async fn delete(app: State, todo_id: TodoId) -> impl IntoEffect 010 Supported cosmetic convention attributes include `data-hemx-pending-class`, `data-hemx-indicator`, `data-hemx-confirm`, and `data-hemx-disable-while-pending`. -011 Supported timing/trigger convention attributes include `data-hemx-debounce`, `data-hemx-delay`, `data-hemx-throttle`, `data-hemx-every`, `data-hemx-interval`, `data-hemx-revealed`, `data-hemx-policy`, and `data-hemx-on`. +011 Supported timing/trigger convention attributes include `data-hemx-debounce`, `data-hemx-delay`, `data-hemx-throttle`, `data-hemx-every`, `data-hemx-interval`, `data-hemx-revealed`, `data-hemx-revealed-ahead`, `data-hemx-policy`, and `data-hemx-on`. 012 Runtime-supported delegated events are `click`, `submit`, `input`, `change`, `dragstart`, `dragover`, and `drop`; unsupported static event names are build errors. 013 Static empty confirmation messages are build errors: they silently disable the guard in browsers. Custom confirm UI belongs to integration crates. -014 `data-hemx-revealed` dispatches once when the element enters view, re-arms after browser page restoration, and has an immediate fallback when `IntersectionObserver` is unavailable. +014 `data-hemx-revealed` dispatches once when the element enters its configured non-negative viewport-ahead margin, tracks viewport resizing, re-arms after browser page restoration, and has an immediate fallback when `IntersectionObserver` is unavailable. 015 Duplicate timers/observers per root are avoided. diff --git a/examples/html_examples/README.md b/examples/html_examples/README.md index 52d78e1..04053b0 100644 --- a/examples/html_examples/README.md +++ b/examples/html_examples/README.md @@ -44,7 +44,7 @@ Status legend: | `edit-row` | implemented | A table row is a keyed `.heml` partial with generated edit/save forms; no selector target strings. | `templates/partials/editable_row.heml`, `editable_row_handlers::edit_row`, `save_row` | | `lazy-load` | implemented | `data-hemx-revealed` dispatches a generated form once when visible; the server swaps a generated lazy panel. | `gallery.heml`, `gallery_handlers::lazy_load`, `LazyPanel` | | `inline-validation` | implemented | A generated form reports field failure with `validate_email_form.error(...)`, focuses the field, and updates status text. | `templates/gallery.heml`, `gallery_handlers::validate_email` | -| `infinite-scroll` | implemented | A revealed sentinel form posts to the same server-owned loading model and replaces generated keyed rows. | `gallery_handlers::infinite_scroll`, `data-hemx-revealed`, `infinite_row` | +| `infinite-scroll` | implemented | A revealed sentinel form posts to the same server-owned loading model and replaces generated keyed rows; `data-hemx-revealed-ahead` opts into viewport-ahead loading without moving the observed element. | `gallery_handlers::infinite_scroll`, `data-hemx-revealed`, `data-hemx-revealed-ahead`, `infinite_row` | | `active-search` | implemented | The search form uses GET URL state; the server derives result rows and reconciles generated keyed partials by removing filtered-out keys, replacing retained keys, and appending newly visible keys. | `gallery_handlers::search`, `SearchResult` | | `progress-bar` | implemented | The Tick progress button advances server-owned progress and replaces a generated progress partial with visible percentage text. | `gallery_handlers::tick_progress`, `ProgressMeter` | | `value-select` | implemented | The first select posts a generated form; the server derives and replaces generated option rows for the second select. | `gallery_handlers::choose_category`, `ValueOption` | diff --git a/examples/html_examples/templates/gallery.heml b/examples/html_examples/templates/gallery.heml index fe7e765..74aca36 100644 --- a/examples/html_examples/templates/gallery.heml +++ b/examples/html_examples/templates/gallery.heml @@ -69,7 +69,7 @@ {+ row +} -
+
diff --git a/hemx-build/src/lib.rs b/hemx-build/src/lib.rs index 8f89e17..27774f1 100644 --- a/hemx-build/src/lib.rs +++ b/hemx-build/src/lib.rs @@ -1623,6 +1623,7 @@ fn known_hemx_attr(name: &str) -> bool { | "data-hemx-every" | "data-hemx-interval" | "data-hemx-revealed" + | "data-hemx-revealed-ahead" | "data-hemx-disable-while-pending" | "data-hemx-policy" | "data-hemx-nav" @@ -1727,6 +1728,20 @@ fn reject_invalid_hemx_attr_values(path: &Path, attrs: &[SurfaceAttribute]) -> i "expected a non-empty same-origin SSE URL", )); } + "data-hemx-revealed-ahead" + if !value + .trim() + .parse::() + .ok() + .is_some_and(|value| value.is_finite() && value >= 0.0) => + { + return Err(invalid_hemx_value( + path, + &attr.name, + value, + "expected a non-negative number of viewports", + )); + } "data-hemx-debounce" | "data-hemx-delay" | "data-hemx-throttle" | "data-hemx-every" | "data-hemx-interval" if !valid_duration(value) => @@ -2774,12 +2789,12 @@ mod tests { let canonical_syms = resources.syms(); assert_eq!( stable_id("generated-rs", &canonical_generated), - 3_748_714_336, + 2_620_423_950, "canonical generated Rust changed" ); assert_eq!( stable_id("generated-rs-global", &canonical_globals), - 2_783_766_475, + 2_559_847_213, "canonical global-export Rust changed" ); assert_eq!( @@ -4223,6 +4238,12 @@ fn main() {{ "data-hemx-delay", "milliseconds", ), + ( + "revealed-ahead-negative", + r#"
"#, + "data-hemx-revealed-ahead", + "non-negative", + ), ( "throttle-invalid", r#""#, @@ -4333,6 +4354,7 @@ fn main() {{ "data-hemx-client-state-version" => { "expected a positive client state ABI version" } + "data-hemx-revealed-ahead" => "expected a non-negative number of viewports", "data-hemx-on" => "expected runtime-supported events: `click`, `submit`, `input`, `change`, `keydown`, `dragstart`, `dragover`, or `drop`", "data-hemx-confirm" => "expected a non-empty confirmation message", "data-hemx-sse" => "expected a non-empty same-origin SSE URL", diff --git a/hemx-js/runtime/hemx.js b/hemx-js/runtime/hemx.js index 5e35ef2..f1d4386 100644 --- a/hemx-js/runtime/hemx.js +++ b/hemx-js/runtime/hemx.js @@ -881,8 +881,14 @@ everyTimers.delete(el); } + function revealedRootMargin(el) { + const ahead = Number(el.getAttribute("data-hemx-revealed-ahead") || "0"); + const viewports = Number.isFinite(ahead) && ahead >= 0 ? ahead : 0; + return `0px 0px ${viewports * window.innerHeight}px 0px`; + } + function bindRevealed(root) { - let observer = revealObservers.get(root); + let observers = revealObservers.get(root); forEachElement(root, (el) => { if (!el.hasAttribute("data-hemx-revealed") || revealed.has(el)) return; if (typeof IntersectionObserver === "undefined") { @@ -890,6 +896,12 @@ schedule(el, "revealed"); return; } + if (!observers) { + observers = new Map(); + revealObservers.set(root, observers); + } + const rootMargin = revealedRootMargin(el); + let observer = observers.get(rootMargin); if (!observer) { observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { @@ -898,8 +910,8 @@ observer.unobserve(entry.target); schedule(entry.target, "revealed"); }); - }); - revealObservers.set(root, observer); + }, { rootMargin }); + observers.set(rootMargin, observer); } observer.observe(el); }); @@ -1123,25 +1135,30 @@ const source = sseSources.get(root); if (source) source.close(); sseSources.delete(root); - const observer = revealObservers.get(root); - if (observer) observer.disconnect(); + const observers = revealObservers.get(root); + if (observers) observers.forEach((observer) => observer.disconnect()); revealObservers.delete(root); stopDescendantPolling(root); } - function restoreRevealed(event) { - if (!event.persisted) return; + function rebindRevealed(resetDispatched) { roots().forEach((root) => { - const observer = revealObservers.get(root); - if (observer) observer.disconnect(); + const observers = revealObservers.get(root); + if (observers) observers.forEach((observer) => observer.disconnect()); revealObservers.delete(root); - forEachElement(root, (el) => { - if (attrEquals(el, "data-hemx-on", "revealed") || el.hasAttribute("data-hemx-revealed")) revealed.delete(el); - }); + if (resetDispatched) { + forEachElement(root, (el) => { + if (attrEquals(el, "data-hemx-on", "revealed") || el.hasAttribute("data-hemx-revealed")) revealed.delete(el); + }); + } bindRevealed(root); }); } + function restoreRevealed(event) { + if (event.persisted) rebindRevealed(true); + } + function start() { roots().forEach((root) => { root.setAttribute("data-hemx-request-timeout-ms", String(REQUEST_TIMEOUT_MS)); @@ -1189,6 +1206,7 @@ } window.addEventListener("pageshow", restoreRevealed); + window.addEventListener("resize", () => rebindRevealed(false)); addEventListener("popstate", (event) => { const root = roots()[0]; diff --git a/hemx-js/tests/runtime.rs b/hemx-js/tests/runtime.rs index feae1e9..dbf92fe 100644 --- a/hemx-js/tests/runtime.rs +++ b/hemx-js/tests/runtime.rs @@ -146,7 +146,10 @@ fn runtime_supports_tiny_delay_and_revealed_scheduling() { assert!(source.contains("const revealObservers = new WeakMap()")); assert!(source.contains("const revealed = new WeakSet()")); assert!(source.contains("data-hemx-revealed")); + assert!(source.contains("data-hemx-revealed-ahead")); // req: convention/011 test assert!(source.contains("typeof IntersectionObserver === \"undefined\"")); + assert!(source.contains("rootMargin")); + assert!(source.contains("addEventListener(\"resize\", () => rebindRevealed(false))")); assert!(source.contains("schedule(entry.target, \"revealed\")")); assert!(source.contains("window.addEventListener(\"pageshow\", restoreRevealed)")); assert!(source.contains("revealed.delete(el)")); // req: convention/014 test