feat(runtime): add tiny trigger timing conventions

Add boring runtime-level trigger timing primitives for delay, revealed, and interval while keeping them as attribute conventions rather than core effects or plugin surfaces. Promote lazy-load, infinite-scroll, progress-bar, value-select, and reset-user-input in examples/html_examples to runnable hemx patterns with exact htmx slugs and generated resources.

req: convention/001

req: convention/003

req: convention/005

req: htmx_equivalents/001

req: htmx_equivalents/002

req: examples/001
This commit is contained in:
slhx agent
2026-06-23 07:51:43 +02:00
parent 0922a46e21
commit cd7f64e5a3
9 changed files with 376 additions and 23 deletions
+1 -1
View File
@@ -59,7 +59,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.
- 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. Do not create a second template language, selector model, formatter, Rust type system, or custom editor framework. req: diagnostics/004 req: diagnostics/005 req: diagnostics/006
- JS runtime changes must preserve root-scoped lookup, fail-closed request handling, root-scoped error outlets, and tiny pending/failure recovery without selectors, VDOM, expressions, or per-node listeners. req: runtime/005 req: convention/007
- JS runtime changes must preserve root-scoped lookup, fail-closed request handling, root-scoped error outlets, and tiny pending/failure/trigger-timing conventions without selectors, VDOM, expressions, or per-node listeners. req: runtime/005 req: convention/001 req: convention/003 req: convention/005 req: convention/007
- Host capability adapters must stay at the `hemx-host` boundary: they may call host APIs and return host events, but they must not mutate DOM or own app/domain state. req: host/002
- Local/offline app behavior should be commands/events/projections; do not add `hemx-local`, stored DOM patches, or stored `EffectBatch` truth without a proven reusable contract. req: local/001 req: local/002
- Axum apps should serve and load the shared runtime through hemx-axum helpers such as `runtime_js_path()` and `runtime_js()`, not hard-coded `/hemx.js` URLs or app-owned cache-busting strings.
+3 -3
View File
@@ -968,19 +968,19 @@ async fn delete(app: State<App>, todo_id: TodoId) -> impl IntoEffect
## convention
### req: convention/001
001 hemx-axum and the JS runtime support common UX conventions as attributes, not core effects: `data-hemx-pending-class`, `data-hemx-indicator`, `data-hemx-confirm`, `data-hemx-debounce`, `data-hemx-throttle`, `data-hemx-every`, `data-hemx-disable-while-pending`, `data-hemx-policy`, and `data-hemx-on`. These are orthogonal to the core effect algebra.
001 hemx-axum and the JS runtime support common UX conventions as attributes, not core effects: `data-hemx-pending-class`, `data-hemx-indicator`, `data-hemx-confirm`, `data-hemx-debounce`, `data-hemx-delay`, `data-hemx-throttle`, `data-hemx-every`, `data-hemx-interval`, `data-hemx-revealed`, `data-hemx-disable-while-pending`, `data-hemx-policy`, and `data-hemx-on`. These are orthogonal to the core effect algebra.
### req: convention/002
002 Default event triggers: `submit` for forms, `click` for buttons and links. `data-hemx-on` overrides the default for the runtime-supported delegated events: `click`, `submit`, `input`, `change`, `dragstart`, `dragover`, and `drop`. Unsupported static event names are build errors.
### req: convention/003
003 `data-hemx-debounce` and `data-hemx-throttle` support simple millisecond values. No trigger mini-language in core.
003 `data-hemx-debounce`, `data-hemx-delay`, and `data-hemx-throttle` support simple millisecond values. No trigger mini-language in core.
### req: convention/004
004 `data-hemx-confirm` dispatches a native `confirm()` before handler dispatch. Static empty confirmation messages are build errors because they silently disable the guard in browsers. Custom confirm UI belongs to integration crates.
### req: convention/005
005 `data-hemx-every` dispatches a handle at a fixed interval while the element remains in the document. Duplicate timers per root are avoided.
005 `data-hemx-every` and `data-hemx-interval` dispatch a handle at a fixed interval while the element remains in the document. `data-hemx-revealed` dispatches once when the element enters view, with an immediate fallback when `IntersectionObserver` is unavailable. Duplicate timers/observers per root are avoided.
### req: convention/006
006 Request concurrency policy (`latest`, `queue`, `drop`, `parallel`) may be declared per handle with `data-hemx-policy`. Default for debounced/input handlers is `latest`; default for form submit is `drop` while pending. Stale EffectBatches from superseded requests must not be applied.
+5 -5
View File
@@ -35,16 +35,16 @@ Status legend:
| `click-to-load` | implemented | The server owns the loaded count and returns generated keyed `loaded_row` replacements plus status text. | `gallery_handlers::load_more`, `LoadedRow` |
| `delete-row` | implemented | Server state removes the row and returns `gallery::editable_row.remove(id)`. | `editable_row_handlers::delete_row` |
| `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` | deferred | Should be the same loaded-row resource with a browser-owned visibility trigger; needs a tiny runtime convention or explicit app JS policy before copy/paste. | Not implemented in core gallery yet. |
| `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` | integration-owned | This is load-more plus scroll/visibility policy; hemx should not own infinite-scroll state without an app proof. | Use `click-to-load` until a product slice proves scroll policy. |
| `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` |
| `active-search` | implemented | The search form posts a query; the server derives result rows and replaces generated `search_result` keyed partials. | `gallery_handlers::search`, `SearchResult` |
| `progress-bar` | deferred | Needs a real job/progress source and polling or push convention; a fake timer would be a trophy demo. | Later slice should prove server-owned job state. |
| `value-select` | deferred | Dependent selects are ordinary generated form state, but need a copyable domain example and validation path. | Later slice should add two select resources and server-derived options. |
| `progress-bar` | implemented | `data-hemx-interval` ticks a server-owned progress value and replaces a generated progress partial. | `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` |
| `animations` | integration-owned | CSS transitions are presentation policy around generated replacements; hemx should only preserve stable DOM boundaries. | Use keyed partials and app CSS; no core animation framework. |
| `file-upload` | integration-owned | Upload transport, size limits, progress, storage, and security are app/integration policy. | Needs product-owned upload route before becoming copyable. |
| `file-upload-input` | integration-owned | Preserving file inputs after errors is browser/security policy; hemx should not fake file state in core effects. | Use app-owned upload form policy. |
| `reset-user-input` | deferred | Generated form `.clear()` already exists; needs a small copyable pattern showing reset after success and preserved values after failure. | Later slice can add to inline-validation. |
| `reset-user-input` | implemented | A generated form updates status and returns `.clear()` after successful submission. | `gallery_handlers::reset_message` |
| `dialogs` | integration-owned | Browser `alert/confirm/prompt` are app policy; hemx can expose event boundaries but should not own dialog UX. | Use native controls or host/app code. |
| `modal-uikit` | refused | Third-party UI kit integration is not a hemx core pattern. | Keep as app-owned integration. |
| `modal-bootstrap` | refused | Third-party UI kit integration is not a hemx core pattern. | Keep as app-owned integration. |
+236 -4
View File
@@ -19,8 +19,14 @@ struct GalleryState {
contacts: Mutex<Vec<ContactRecord>>,
rows: Mutex<Vec<RowRecord>>,
loaded_count: Mutex<usize>,
infinite_count: Mutex<usize>,
lazy_loaded: Mutex<bool>,
progress: Mutex<u8>,
email: Mutex<String>,
email_status: Mutex<String>,
category: Mutex<String>,
value: Mutex<String>,
reset_status: Mutex<String>,
query: Mutex<String>,
}
@@ -76,6 +82,11 @@ struct LoadMore {
request: String,
}
#[hemx::form("infinite_scroll")]
struct InfiniteScroll {
request: String,
}
#[hemx::form("save_contact")]
struct SaveContact {
id: Id,
@@ -89,11 +100,31 @@ struct SaveRow {
title: String,
}
#[hemx::form("lazy_load")]
struct LazyLoad {
request: String,
}
#[hemx::form("tick_progress")]
struct TickProgress {
request: String,
}
#[hemx::form("validate_email")]
struct ValidateEmail {
email: String,
}
#[hemx::form("choose_category")]
struct ChooseCategory {
category: String,
}
#[hemx::form("reset_message")]
struct ResetMessage {
message: String,
}
#[hemx::form("search")]
struct SearchInput {
query: String,
@@ -109,10 +140,17 @@ struct AppShell {
struct Gallery {
contacts: Vec<ContactCard>,
rows: Vec<EditableRow>,
email: String,
email_status: String,
lazy_panel: String,
loaded_rows: Vec<LoadedRow>,
load_status: String,
infinite_rows: Vec<LoadedRow>,
infinite_status: String,
progress: u8,
progress_label: String,
email: String,
email_status: String,
value_options: Vec<ValueOption>,
reset_status: String,
query: String,
search_status: String,
search_results: Vec<SearchResult>,
@@ -157,6 +195,20 @@ impl hemx::KeyedPartial for LoadedRow {
}
}
#[derive(Hemplate, Clone)]
struct ValueOption {
id: Id,
value: String,
label: String,
selected: bool,
}
impl hemx::KeyedPartial for ValueOption {
fn hemx_key(&self) -> String {
self.id.to_string()
}
}
#[derive(Hemplate, Clone)]
struct SearchResult {
id: Id,
@@ -205,8 +257,14 @@ impl GalleryState {
},
]),
loaded_count: Mutex::new(2),
infinite_count: Mutex::new(3),
lazy_loaded: Mutex::new(false),
progress: Mutex::new(0),
email: Mutex::new(String::new()),
email_status: Mutex::new("Waiting for an email".into()),
category: Mutex::new("letters".into()),
value: Mutex::new("alpha".into()),
reset_status: Mutex::new("No message sent yet".into()),
query: Mutex::new(String::new()),
}
}
@@ -260,17 +318,34 @@ fn gallery_view(state: &GalleryState) -> Gallery {
.map(editable_row_view)
.collect();
let loaded_count = *state.loaded_count.lock().unwrap();
let infinite_count = *state.infinite_count.lock().unwrap();
let lazy_loaded = *state.lazy_loaded.lock().unwrap();
let progress = *state.progress.lock().unwrap();
let email = state.email.lock().unwrap().clone();
let email_status = state.email_status.lock().unwrap().clone();
let category = state.category.lock().unwrap().clone();
let value = state.value.lock().unwrap().clone();
let reset_status = state.reset_status.lock().unwrap().clone();
let query = state.query.lock().unwrap().clone();
let search_results = search_results_for(&query);
Gallery {
contacts,
rows,
email,
email_status,
lazy_panel: if lazy_loaded {
"Lazy content loaded".into()
} else {
"Waiting to be revealed".into()
},
loaded_rows: loaded_rows(loaded_count),
load_status: format!("Showing {loaded_count} rows"),
infinite_rows: loaded_rows(infinite_count),
infinite_status: format!("Showing {infinite_count} rows"),
progress,
progress_label: format!("{progress}% complete"),
email,
email_status,
value_options: value_options_for(&category, &value),
reset_status,
query: query.clone(),
search_status: if query.is_empty() {
"Showing all results".into()
@@ -307,6 +382,22 @@ fn loaded_rows(count: usize) -> Vec<LoadedRow> {
.collect()
}
fn value_options_for(category: &str, selected: &str) -> Vec<ValueOption> {
let values = match category {
"numbers" => [(1, "one", "One"), (2, "two", "Two")],
_ => [(1, "alpha", "Alpha"), (2, "beta", "Beta")],
};
values
.into_iter()
.map(|(id, value, label)| ValueOption {
id: Id(id),
value: value.into(),
label: label.into(),
selected: value == selected,
})
.collect()
}
fn search_results_for(query: &str) -> Vec<SearchResult> {
["Alpha", "Beta", "Gamma", "Delta"]
.into_iter()
@@ -325,6 +416,86 @@ fn search_results_for(query: &str) -> Vec<SearchResult> {
mod gallery_handlers {
use super::*;
#[hemx::handler]
async fn lazy_load(
State(state): State<Arc<GalleryState>>,
Form(input): Form<LazyLoad>,
) -> impl IntoEffect {
let _ = input.request;
*state.lazy_loaded.lock().unwrap() = true;
gallery::lazy_panel.set("Lazy content loaded")
}
#[hemx::handler]
async fn tick_progress(
State(state): State<Arc<GalleryState>>,
Form(input): Form<TickProgress>,
) -> impl IntoEffect {
let _ = input.request;
let mut progress = state.progress.lock().unwrap();
*progress = (*progress + 25).min(100);
gallery::progress_meter.set(format!("{}% complete", *progress))
}
#[hemx::handler]
async fn choose_category(
State(state): State<Arc<GalleryState>>,
Form(input): Form<ChooseCategory>,
) -> impl IntoEffect {
let category = if input.category == "numbers" {
"numbers"
} else {
"letters"
}
.to_owned();
let value = if category == "numbers" {
"one"
} else {
"alpha"
}
.to_owned();
*state.category.lock().unwrap() = category.clone();
*state.value.lock().unwrap() = value.clone();
value_options_for(&category, &value)
.into_iter()
.map(|option| gallery::value_option.replace(option))
.collect::<Vec<Effect>>()
}
#[hemx::handler]
async fn reset_message(
State(state): State<Arc<GalleryState>>,
Form(input): Form<ResetMessage>,
) -> impl IntoEffect {
let status = if input.message.trim().is_empty() {
"Nothing to send".to_owned()
} else {
format!("Sent: {}", input.message.trim())
};
*state.reset_status.lock().unwrap() = status.clone();
vec![
gallery::reset_status.set(status),
gallery::reset_message_form.clear(),
]
}
#[hemx::handler]
async fn infinite_scroll(
State(state): State<Arc<GalleryState>>,
Form(input): Form<InfiniteScroll>,
) -> impl IntoEffect {
let _ = input.request;
let mut count = state.infinite_count.lock().unwrap();
*count += 3;
let rows = loaded_rows(*count)
.into_iter()
.map(|row| gallery::infinite_row.replace(row))
.collect::<Vec<Effect>>();
let mut effects = rows;
effects.push(gallery::infinite_status.set(format!("Showing {} rows", *count)));
effects
}
#[hemx::handler]
async fn validate_email(
State(state): State<Arc<GalleryState>>,
@@ -531,8 +702,13 @@ mod tests {
"click-to-edit",
"edit-row",
"delete-row",
"lazy-load",
"inline-validation",
"infinite-scroll",
"click-to-load",
"progress-bar",
"value-select",
"reset-user-input",
"active-search",
] {
assert!(
@@ -541,7 +717,11 @@ mod tests {
);
}
assert!(html.contains("data-hemx-form=\"validate_email\""));
assert!(html.contains("data-hemx-revealed=\"true\""));
assert!(html.contains("data-hemx-interval=\"500ms\""));
assert!(html.contains("data-hemx-slot=\"loaded_row\""));
assert!(html.contains("data-hemx-slot=\"infinite_row\""));
assert!(html.contains("data-hemx-slot=\"value_option\""));
assert!(html.contains("data-hemx-slot=\"search_result\""));
let edit = inspect_batch(
@@ -565,6 +745,16 @@ mod tests {
);
assert!(save.updates_html_containing(gallery::contact_card, "ada@hemx.test"));
let lazy = inspect_batch(
InteractionRequest::from(form(gallery::lazy_load, &[("request", "lazy")]))
.dispatch_async(handlers(state.clone()))
.await
.unwrap()
.batch,
);
assert!(lazy.updates_text(gallery::lazy_panel));
assert!(lazy.payload_contains("Lazy content loaded"));
let invalid = inspect_batch(
InteractionRequest::from(form(gallery::validate_email, &[("email", "bad")]))
.dispatch_async(handlers(state.clone()))
@@ -575,6 +765,48 @@ mod tests {
assert!(invalid.payload_contains("Use a real email address"));
assert!(invalid.updates_text(gallery::email_status));
let infinite = inspect_batch(
InteractionRequest::from(form(gallery::infinite_scroll, &[("request", "more")]))
.dispatch_async(handlers(state.clone()))
.await
.unwrap()
.batch,
);
assert!(infinite.replaces_keyed_html_containing(
gallery::infinite_row,
"6",
"Loaded row 6"
));
let progress = inspect_batch(
InteractionRequest::from(form(gallery::tick_progress, &[("request", "tick")]))
.dispatch_async(handlers(state.clone()))
.await
.unwrap()
.batch,
);
assert!(progress.updates_text(gallery::progress_meter));
assert!(progress.payload_contains("25% complete"));
let values = inspect_batch(
InteractionRequest::from(form(gallery::choose_category, &[("category", "numbers")]))
.dispatch_async(handlers(state.clone()))
.await
.unwrap()
.batch,
);
assert!(values.replaces_keyed_html_containing(gallery::value_option, "1", "One"));
let reset = inspect_batch(
InteractionRequest::from(form(gallery::reset_message, &[("message", "hello")]))
.dispatch_async(handlers(state.clone()))
.await
.unwrap()
.batch,
);
assert!(reset.updates_text(gallery::reset_status));
assert!(reset.resets_form(gallery::reset_message_form));
let load = inspect_batch(
InteractionRequest::from(form(gallery::load_more, &[("request", "more")]))
.dispatch_async(handlers(state.clone()))
@@ -39,6 +39,15 @@
</form>
</section>
<section id="lazy-load" data-htmx-example="lazy-load" aria-labelledby="lazy-heading">
<h2 id="lazy-heading">lazy-load</h2>
<form data-hemx-handle="lazy_load" data-hemx-form="lazy_load" data-hemx-revealed="true">
<input type="hidden" name="request" value="lazy">
<button type="submit">Load lazy content</button>
</form>
<div data-hemx-slot="lazy_panel">{+ self.lazy_panel +}</div>
</section>
<section id="click-to-load" data-htmx-example="click-to-load" aria-labelledby="load-heading">
<h2 id="load-heading">click-to-load</h2>
<ul data-hemx-slot="loaded_row">
@@ -53,6 +62,57 @@
<p data-hemx-slot="load_status">{+ self.load_status +}</p>
</section>
<section id="infinite-scroll" data-htmx-example="infinite-scroll" aria-labelledby="infinite-heading">
<h2 id="infinite-heading">infinite-scroll</h2>
<ul data-hemx-slot="infinite_row">
<template h-for="row in &self.infinite_rows" h-key="row.id">
{+ row +}
</template>
</ul>
<form data-hemx-handle="infinite_scroll" data-hemx-form="infinite_scroll" data-hemx-revealed="true">
<input type="hidden" name="request" value="more">
<button type="submit">Reveal more rows</button>
</form>
<p data-hemx-slot="infinite_status">{+ self.infinite_status +}</p>
</section>
<section id="progress-bar" data-htmx-example="progress-bar" aria-labelledby="progress-heading">
<h2 id="progress-heading">progress-bar</h2>
<form data-hemx-handle="tick_progress" data-hemx-form="tick_progress" data-hemx-interval="500ms">
<input type="hidden" name="request" value="tick">
<button type="submit">Tick progress</button>
</form>
<progress max="100" +value="self.progress">{+ self.progress_label +}</progress>
<p data-hemx-slot="progress_meter">{+ self.progress_label +}</p>
</section>
<section id="value-select" data-htmx-example="value-select" aria-labelledby="value-heading">
<h2 id="value-heading">value-select</h2>
<form data-hemx-handle="choose_category" data-hemx-form="choose_category">
<label>Category
<select name="category">
<option value="letters">Letters</option>
<option value="numbers">Numbers</option>
</select>
</label>
<button type="submit">Choose</button>
</form>
<select data-hemx-slot="value_option" name="value">
<template h-for="option in &self.value_options" h-key="option.id">
{+ option +}
</template>
</select>
</section>
<section id="reset-user-input" data-htmx-example="reset-user-input" aria-labelledby="reset-heading">
<h2 id="reset-heading">reset-user-input</h2>
<form data-hemx-handle="reset_message" data-hemx-form="reset_message">
<label>Message <input name="message"></label>
<button type="submit">Send and reset</button>
</form>
<p data-hemx-slot="reset_status">{+ self.reset_status +}</p>
</section>
<section id="active-search" data-htmx-example="active-search" aria-labelledby="search-heading">
<h2 id="search-heading">active-search</h2>
<form data-hemx-handle="search" data-hemx-form="search">
@@ -0,0 +1 @@
<option +data-key="self.id" +value="self.value" +selected="self.selected">{+ self.label +}</option>
+17 -1
View File
@@ -1534,8 +1534,11 @@ fn known_hemx_attr(name: &str) -> bool {
| "data-hemx-indicator"
| "data-hemx-confirm"
| "data-hemx-debounce"
| "data-hemx-delay"
| "data-hemx-throttle"
| "data-hemx-every"
| "data-hemx-interval"
| "data-hemx-revealed"
| "data-hemx-disable-while-pending"
| "data-hemx-policy"
| "data-hemx-nav"
@@ -1585,7 +1588,8 @@ fn reject_invalid_hemx_attr_values(path: &Path, attrs: &[SurfaceAttribute]) -> i
"expected a non-empty same-origin SSE URL",
));
}
"data-hemx-debounce" | "data-hemx-throttle" | "data-hemx-every"
"data-hemx-debounce" | "data-hemx-delay" | "data-hemx-throttle" | "data-hemx-every"
| "data-hemx-interval"
if !valid_duration(value) =>
{
return Err(invalid_hemx_value(
@@ -2743,12 +2747,24 @@ fn main() {{
"data-hemx-debounce",
"250ms",
),
(
"delay",
r#"<button data-hemx-handle="save" data-hemx-delay="soon">Save</button>"#,
"data-hemx-delay",
"250ms",
),
(
"every",
r#"<button data-hemx-handle="save" data-hemx-every="1sec">Save</button>"#,
"data-hemx-every",
"1s",
),
(
"interval",
r#"<button data-hemx-handle="save" data-hemx-interval="often">Save</button>"#,
"data-hemx-interval",
"1s",
),
(
"event",
r#"<button data-hemx-handle="save" data-hemx-on="keydown">Save</button>"#,
+34 -3
View File
@@ -9,6 +9,8 @@
const queues = new WeakMap();
const timers = new WeakMap();
const everyTimers = new WeakMap();
const revealObservers = new WeakMap();
const revealed = new WeakSet();
const pendingClassStates = new WeakMap();
const indicatorStates = new WeakMap();
const busyStates = new WeakMap();
@@ -654,14 +656,18 @@
});
});
bindPolling(root);
bindRevealed(root);
}
function schedule(el, eventName, source = el) {
const debounce = duration(el.getAttribute("data-hemx-debounce"));
const delay = duration(el.getAttribute("data-hemx-delay"));
const throttle = duration(el.getAttribute("data-hemx-throttle"));
if (debounce) {
clearTimeout(timers.get(el));
timers.set(el, setTimeout(() => send(el, eventName, source), debounce));
} else if (delay) {
setTimeout(() => send(el, eventName, source), delay);
} else if (throttle) {
if (timers.get(el)) return;
send(el, eventName, source).finally(() => setTimeout(() => timers.delete(el), throttle));
@@ -672,10 +678,11 @@
function bindPolling(root) {
forEachElement(root, (el) => {
if (!el.hasAttribute("data-hemx-every") || everyTimers.has(el)) return;
const ms = duration(el.getAttribute("data-hemx-every"));
if ((!el.hasAttribute("data-hemx-every") && !el.hasAttribute("data-hemx-interval")) || everyTimers.has(el)) return;
const eventName = el.hasAttribute("data-hemx-interval") ? "interval" : "every";
const ms = duration(el.getAttribute("data-hemx-interval") || el.getAttribute("data-hemx-every"));
if (!ms) return;
everyTimers.set(el, setInterval(() => document.contains(el) ? send(el, "every") : stopPolling(el), ms));
everyTimers.set(el, setInterval(() => document.contains(el) ? send(el, eventName) : stopPolling(el), ms));
});
}
@@ -684,6 +691,30 @@
everyTimers.delete(el);
}
function bindRevealed(root) {
let observer = revealObservers.get(root);
forEachElement(root, (el) => {
if (!el.hasAttribute("data-hemx-revealed") || revealed.has(el)) return;
if (typeof IntersectionObserver === "undefined") {
revealed.add(el);
schedule(el, "revealed");
return;
}
if (!observer) {
observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting || revealed.has(entry.target)) return;
revealed.add(entry.target);
observer.unobserve(entry.target);
schedule(entry.target, "revealed");
});
});
revealObservers.set(root, observer);
}
observer.observe(el);
});
}
function bindSse(root) {
const url = root.getAttribute("data-hemx-sse");
if (!url || sseSources.has(root) || typeof EventSource === "undefined") return;
+19 -6
View File
@@ -101,19 +101,32 @@ fn runtime_interval_dispatch_avoids_duplicate_timers() {
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("const everyTimers = new WeakMap()"));
assert!(source.contains("forEachElement(root, (el) =>"));
assert!(source.contains("!el.hasAttribute(\"data-hemx-every\") || everyTimers.has(el)"));
assert!(
source.contains("if (!el.hasAttribute(\"data-hemx-every\") || everyTimers.has(el)) return")
);
assert!(source.contains("data-hemx-interval"));
assert!(source.contains(
"setInterval(() => document.contains(el) ? send(el, \"every\") : stopPolling(el), ms)"
"eventName = el.hasAttribute(\"data-hemx-interval\") ? \"interval\" : \"every\""
));
assert!(source.contains(
"setInterval(() => document.contains(el) ? send(el, eventName) : stopPolling(el), ms)"
));
assert!(source.contains("function stopPolling(el)"));
assert!(source.contains("clearInterval(everyTimers.get(el))"));
assert!(source.contains("everyTimers.delete(el)"));
}
#[test]
fn runtime_supports_tiny_delay_and_revealed_scheduling() {
// req: convention/001 req: convention/003 req: convention/005
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("data-hemx-delay"));
assert!(source.contains("setTimeout(() => send(el, eventName, source), delay)"));
assert!(source.contains("const revealObservers = new WeakMap()"));
assert!(source.contains("const revealed = new WeakSet()"));
assert!(source.contains("data-hemx-revealed"));
assert!(source.contains("typeof IntersectionObserver === \"undefined\""));
assert!(source.contains("schedule(entry.target, \"revealed\")"));
}
#[test]
fn runtime_toggles_pending_conventions_around_requests() {
// req: convention/007 req: convention/008