From 6e6efa001276bdd4cca79cdcb428a6ce538b9212 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Thu, 25 Jun 2026 18:24:35 +0200 Subject: [PATCH] test(html-examples): reconcile keyed search rows Make the active-search example use a safer keyed reconciliation path for filtered rows: remove filtered-out keys, replace retained keys, and append newly visible keys. req: list/006 req: htmx_equivalents/001 req: examples/001 --- AGENTS.md | 2 +- REQUIREMENTS.md | 3 ++ examples/html_examples/README.md | 2 +- examples/html_examples/src/main.rs | 77 ++++++++++++++++++++++++++---- 4 files changed, 72 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 32d4ffe..3284824 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,7 +58,7 @@ Keep it stable. Prefer pointers to canonical sources over copied structure, file - hemx core stays small: effects, typed ids, registries, and wire schema only; keep features in core only when they fit typed resources plus the closed EffectBatch op set, and treat DOM details as runtime lowering. Workspace crates stay separated, stable-Rust-compatible, and free of kitchen-sink boundaries; new primitives must delete special cases. Public identifiers should flow through typed wrappers over internal `ResourceId`/`ResourceRef`, not special-case opcodes. Wire output lowers symbolic authoring names to compact metadata and postcard/form-encoded envelopes, not JSON. ABI/schema versions and build fingerprints must guard runtime/server compatibility. v0 scope is the checked hypermedia core plus page/runtime/wire/diagnostic/test/axum proof, not optional sync/wasm/query/auth/router breadth. req: v0_scope/001 req: v0_scope/002 req: v0_scope/005 req: laws/001 req: invariant/001 req: invariant/005 req: typed_id/001 req: typed_id/003 req: effect_algebra/001 req: effect_algebra/006 req: wire/001 req: wire/002 req: wire/003 req: wire/004 req: wire/005 req: wire/006 req: abi/001 req: abi/002 req: abi/003 req: abi/004 req: abi/005 req: misc/001 req: misc/002 req: misc/003 req: misc/004 req: misc/005 req: misc/006 req: misc/007 req: misc/008 req: misc/009 req: misc/010 - Routing, auth, sessions, transport, transitions, sync, async data helpers, multipart parsing/uploads, and storage belong in integration/user crates; hemx-axum preserves normal HTTP auth, credentials, CSRF, multipart/browser fallback, and progressive-enhancement semantics rather than defining policy in core. Sync is optional integration state reconciliation over push/transport, not core. req: auth/001 req: auth/002 req: auth/003 req: auth/004 req: auth/005 req: async_data/001 req: async_data/002 req: async_data/003 req: multipart/001 req: multipart/002 req: multipart/003 req: sync/001 req: sync/008 - Public examples and beginner APIs should use templates plus Rust, generated component APIs, resources, view wrappers, render/page helpers, `#[hemx::app]`, plain `#[hemx::handler]` functions, and `IntoEffect`, not atoms, raw ids, selectors, wire formats, runtime opcodes, manual registries, `$OUT_DIR` includes, raw render/lower calls, raw HTML construction, imperative DOM mutation, or raw effect constructors; keep advanced layers out of starters. req: canonical_authoring/001 req: canonical_authoring/004 req: canonical_authoring/006 req: canonical_authoring/010 req: canonical_authoring/015 req: invariant/003 req: dx/001 req: dx/002 req: dx/010 req: component/003 req: component/004 req: view/001 req: view/002 req: html_safety/001 req: html_safety/003 req: html_safety/005 req: public_api/001 req: public_api/002 req: public_api/003 req: public_api/005 req: public_api/006 req: progressive_disclosure/001 req: progressive_disclosure/002 req: progressive_disclosure/003 req: derive_app/001 req: derive_app/002 req: derive_handler/001 req: derive_handler/002 req: derive_handler/003 req: derive_handler/004 req: derive_handler/005 -- Typed partial swaps should stay expressed as generated target plus rendered partial plus swap kind, not selector-driven rerendering or response-side selector retargeting; HTTP, page navigation, push, and island behavior adapt around that loop, and docs should layer new primitives progressively. Navigation is an effect/page-swap concern, not a core router framework, and enhanced links preserve real anchors/history semantics. Push streams carry postcard EffectBatch over server-owned SSE/WebSocket transport and keep `data-hemx-sse` root-scoped/same-origin by default. Preserve keyed/optional scope identity for addressable loop nodes, prefer generated keyed-slot helpers over low-level keyed calls, and route self/row-update diagnostics toward local `data-hemx-slot`/`h-key` targets. req: canonical_authoring/002 req: canonical_authoring/014 req: modes/001 req: scope/001 req: list/001 req: list/002 req: list/003 req: list/004 req: list/005 req: nav/001 req: nav/002 req: nav/003 req: nav/004 req: nav/005 req: push/001 req: push/002 req: push/003 req: push/004 req: push/005 req: push/006 req: push/007 req: progressive_disclosure/004 req: page_swap/001 req: page_swap/002 req: page_swap/003 req: locality/001 req: locality/002 req: target_policy/001 req: target_policy/002 +- Typed partial swaps should stay expressed as generated target plus rendered partial plus swap kind, not selector-driven rerendering or response-side selector retargeting; HTTP, page navigation, push, and island behavior adapt around that loop, and docs should layer new primitives progressively. Navigation is an effect/page-swap concern, not a core router framework, and enhanced links preserve real anchors/history semantics. Push streams carry postcard EffectBatch over server-owned SSE/WebSocket transport and keep `data-hemx-sse` root-scoped/same-origin by default. Preserve keyed/optional scope identity for addressable loop nodes, reconcile filtered keyed collections without clearing retained rows, prefer generated keyed-slot helpers over low-level keyed calls, and route self/row-update diagnostics toward local `data-hemx-slot`/`h-key` targets. req: canonical_authoring/002 req: canonical_authoring/014 req: modes/001 req: scope/001 req: list/001 req: list/002 req: list/003 req: list/004 req: list/005 req: list/006 req: nav/001 req: nav/002 req: nav/003 req: nav/004 req: nav/005 req: push/001 req: push/002 req: push/003 req: push/004 req: push/005 req: push/006 req: push/007 req: progressive_disclosure/004 req: page_swap/001 req: page_swap/002 req: page_swap/003 req: locality/001 req: locality/002 req: target_policy/001 req: target_policy/002 - `examples/html_examples` is the copy-paste HTML pattern gallery for htmx-style examples; keep exact htmx URL slugs visible while translating behavior to boring `.heml`, generated resources, and server-owned Rust state, not HTMX syntax, selector targeting, or user-authored browser JavaScript. Shared runtime loading and declarative `data-hemx-*` are allowed. Boost containers enhance same-origin descendants only and preserve native external/download/new-tab behavior. req: htmx_equivalents/001 req: htmx_equivalents/003 req: htmx_equivalents/005 req: examples/005 req: examples/007 req: examples/012 req: page_swap/007 req: page_swap/008 - Use `cargo run -p hemx-xtask -- app new PATH` for the generic page/form/keyed-row/notice starter, and `cargo run -p hemx-xtask -- app new --mobile PATH` for the phone-first starter with host capabilities, recovery truth, and release-kit commands; do not treat it as a mobile framework or store-submission bot. req: ceremony/005 req: ceremony/006 req: ceremony/007 - The public component-reuse explanation lives in `docs/recipes/reusable-partials.md`; do not grow a client component framework to explain partial composition. diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 24bbcd4..96683e5 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -437,6 +437,9 @@ resources. Concrete runtime targets are addressed through `ResourceRef` ### req: list/005 0 005 Compatibility functions such as `append(keyed_slot, key, view)` may exist as explicit low-level forms. Key type mismatches are compile-time errors; missing/ambiguous keys are build errors with template spans. [north_star] +### req: list/006 +0 006 Filtered keyed collections reconcile by removing filtered-out keys, replacing retained keys, and appending newly visible keys rather than clearing and re-adding every row. [north_star] + --- ## form diff --git a/examples/html_examples/README.md b/examples/html_examples/README.md index e722bde..9e72c9c 100644 --- a/examples/html_examples/README.md +++ b/examples/html_examples/README.md @@ -38,7 +38,7 @@ Status legend: | `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` | -| `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` | +| `active-search` | implemented | The search form posts a query; 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`, `keyed_filter_effects`, `SearchResult` | | `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. | diff --git a/examples/html_examples/src/main.rs b/examples/html_examples/src/main.rs index 0b2e2f2..62ecec0 100644 --- a/examples/html_examples/src/main.rs +++ b/examples/html_examples/src/main.rs @@ -458,6 +458,47 @@ fn search_results_for(query: &str) -> Vec { .collect() } +// Keep filtered keyed collections stable: remove filtered-out rows, replace retained +// rows, and append newly visible rows instead of clearing the whole list. req: list/006 +fn keyed_filter_effects( + previous: impl IntoIterator, + current: impl IntoIterator, + key: impl Fn(&T) -> K, + append: impl Fn(T) -> hemx::advanced::Effect, + replace: impl Fn(T) -> hemx::advanced::Effect, + remove: impl Fn(String) -> hemx::advanced::Effect, +) -> Vec +where + K: ToString, +{ + let previous_keys = previous + .into_iter() + .map(|item| key(&item).to_string()) + .collect::>(); + let current = current + .into_iter() + .map(|item| (key(&item).to_string(), item)) + .collect::>(); + let current_keys = current + .iter() + .map(|(key, _)| key.clone()) + .collect::>(); + + let mut effects = previous_keys + .difference(¤t_keys) + .cloned() + .map(remove) + .collect::>(); + effects.extend(current.into_iter().map(|(item_key, item)| { + if previous_keys.contains(&item_key) { + replace(item) + } else { + append(item) + } + })); + effects +} + #[hemx::component("gallery")] mod gallery_handlers { use super::*; @@ -599,15 +640,13 @@ mod gallery_handlers { *stored_query = query.clone(); previous_query }; - let previous_results = search_results_for(&previous_query); - let mut effects = previous_results - .into_iter() - .map(|result| gallery::search_result.remove(result.id.to_string())) - .collect::>(); - effects.extend( - search_results_for(&query) - .into_iter() - .map(|result| gallery::search_result.append(result)), + let mut effects = keyed_filter_effects( + search_results_for(&previous_query), + search_results_for(&query), + |result| result.id, + |result| gallery::search_result.append(result), + |result| gallery::search_result.replace(result), + |key| gallery::search_result.remove(key), ); effects.push(gallery::search_status.set(if query.is_empty() { "Showing all results".into() @@ -944,7 +983,25 @@ mod tests { .batch, ); assert!(search.removes_key(gallery::search_result, "1")); - assert!(search.inserts_html_containing(gallery::search_result, "3", "Gamma")); + assert!(search.removes_key(gallery::search_result, "2")); + assert!(search.removes_key(gallery::search_result, "4")); + assert!(search.replaces_keyed_html_containing(gallery::search_result, "3", "Gamma")); + + let broader_search = inspect_batch( + InteractionRequest::from(form(gallery::search, &[("query", "a")])) + .dispatch_async(handlers(state.clone())) + .await + .unwrap() + .batch, + ); + assert!(broader_search.inserts_html_containing(gallery::search_result, "1", "Alpha")); + assert!(broader_search.inserts_html_containing(gallery::search_result, "2", "Beta")); + assert!(broader_search.replaces_keyed_html_containing( + gallery::search_result, + "3", + "Gamma" + )); + assert!(broader_search.inserts_html_containing(gallery::search_result, "4", "Delta")); let row_save = inspect_batch( InteractionRequest::from(form(