feat(runtime): support viewport-ahead reveals

This commit is contained in:
slhx agent
2026-08-02 22:06:18 +02:00
parent c530dc5d8f
commit da28db9775
7 changed files with 62 additions and 19 deletions
+1 -1
View File
@@ -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
+2 -2
View File
@@ -1005,13 +1005,13 @@ async fn delete(app: State<App>, 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.
+1 -1
View File
@@ -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` |
@@ -69,7 +69,7 @@
{+ row +}
</template>
</ul>
<form data-hemx-handle="infinite_scroll" data-hemx-form="infinite_scroll" data-hemx-revealed="true">
<form data-hemx-handle="infinite_scroll" data-hemx-form="infinite_scroll" data-hemx-revealed="true" data-hemx-revealed-ahead="1">
<input type="hidden" name="request" value="more">
<button type="submit">Reveal more rows</button>
</form>
+24 -2
View File
@@ -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::<f64>()
.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#"<form data-hemx-revealed data-hemx-revealed-ahead="-1"></form>"#,
"data-hemx-revealed-ahead",
"non-negative",
),
(
"throttle-invalid",
r#"<button data-hemx-handle="save" data-hemx-throttle="ms">Save</button>"#,
@@ -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",
+30 -12
View File
@@ -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];
+3
View File
@@ -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