diff --git a/docs/api-migrations.md b/docs/api-migrations.md index 91c73723..b2eb9675 100644 --- a/docs/api-migrations.md +++ b/docs/api-migrations.md @@ -14,6 +14,22 @@ covered separately. ## Unreleased: component API and MyGo parity +### Pending-aware application model reads + +`Lui_app.model app` now returns the latest reduced model even before a pending +signal update is published. This applies to regular, extension-enabled and +reloadable application constructors. Reading the model does not flush the +scheduler, run view updates or emit patches; view signals keep their existing +batched publication semantics. + +An async context fence can capture `(Lui_app.model app).context_revision` before +starting a request and read it again before accepting the response. The revision +must be incremented by the reducer for every context publication, including an +A-to-B-to-A sequence in one flush. A published view signal cannot replace this +fresh read because intermediate updates may be coalesced. Existing callers that +read after `Lui_app.flush` require no changes; callers that intentionally need a +published view snapshot must read their borrowed model signal instead. + ### MyGo transparent dynamic anchors `box` nodes with `display="contents"` now render their children directly into diff --git a/src/lui_app.ml b/src/lui_app.ml index a95da77b..b50d7222 100644 --- a/src/lui_app.ml +++ b/src/lui_app.ml @@ -32,6 +32,11 @@ type ('model, 'action) reducer_app = { let reduce model_state reducer action = Signal.update model_state (fun current -> reducer current action) +let latest_model model_state = + match Signal.state_pending model_state with + | Some model -> model + | None -> Signal.get_state model_state + let restore_state_scopes state_scopes saved = Hashtbl.iter (fun path scope -> @@ -82,7 +87,7 @@ let create_with_extensions ?virtual_rows backend registry initial_model reducer app_scheduler = scheduler; app_runtime = application; app_scope = scope; - app_read_model = (fun () -> Signal.get_state model_state); + app_read_model = (fun () -> latest_model model_state); app_send_action = send_action; app_root_node = root; app_lifecycle_state = lifecycle; @@ -128,7 +133,7 @@ let create_reloadable_with_extensions ?virtual_rows backend registry source_hash app_scheduler = scheduler; app_runtime = application; app_scope = scope; - app_read_model = (fun () -> Signal.get_state model_state); + app_read_model = (fun () -> latest_model model_state); app_send_action = send_action; app_root_node = root; app_lifecycle_state = lifecycle; diff --git a/src/lui_app.mli b/src/lui_app.mli index 4f1c90f3..985be32d 100644 --- a/src/lui_app.mli +++ b/src/lui_app.mli @@ -74,6 +74,9 @@ val start : ('a, 'b) reducer_app -> bool val flush : ('a, 'b) reducer_app -> bool val dispose : ('a, 'b) reducer_app -> bool val disposed : ('a, 'b) reducer_app -> bool + +(** Read the latest reduced model, including pending actions, without flushing + signal publication or rendering patches. *) val model : ('a, 'b) reducer_app -> 'a val root_node : ('a, 'b) reducer_app -> int val runtime : ('a, 'b) reducer_app -> Lui_runtime.application diff --git a/test/test_lui.ml b/test/test_lui.ml index 01e2c162..299883b1 100644 --- a/test/test_lui.ml +++ b/test/test_lui.ml @@ -103,6 +103,39 @@ let test_backend_receives_patches () = !batches); ignore (Lui_app.dispose app) +let test_model_reads_pending_reductions () = + let constructors = + [ "plain", (fun backend -> + Lui_app.create backend 0 counter_reducer counter_view) + ; "extensions", (fun backend -> + Lui_app.create_with_extensions backend (Lui_extension.registry ()) + 0 counter_reducer counter_view) + ; "reloadable", (fun backend -> + Lui_app.create_reloadable backend "source" "contract" + 0 counter_reducer counter_view) + ; "reloadable extensions", (fun backend -> + Lui_app.create_reloadable_with_extensions backend + (Lui_extension.registry ()) "source" "contract" + 0 counter_reducer counter_view) + ] + in + List.iter + (fun (name, create) -> + let app = create (recording_backend ()) in + ignore (Lui_app.start app); + let batch_count = List.length !batches in + ignore (Lui_app.send app `Increment); + ignore (Lui_app.send app `Increment); + Alcotest.(check int) (name ^ ": pending reductions") + 2 (Lui_app.model app); + Alcotest.(check int) (name ^ ": reading does not flush") + batch_count (List.length !batches); + flush_app app; + Alcotest.(check int) (name ^ ": published reductions") + 2 (Lui_app.model app); + ignore (Lui_app.dispose app)) + constructors + (* A node mounted and dropped inside one pending batch has its ops elided; surviving insert/move indices it offset must be renumbered so the stream stays incrementally valid for the backend. *) @@ -5610,6 +5643,8 @@ let () = Alcotest.test_case "ordered picked file batches" `Quick test_picked_files_batch; Alcotest.test_case "gallery composer batches" `Quick test_gallery_composer_batches; Alcotest.test_case "lifecycle" `Quick test_app_lifecycle; + Alcotest.test_case "pending model reads" `Quick + test_model_reads_pending_reductions; Alcotest.test_case "backend patches" `Quick test_backend_receives_patches; Alcotest.test_case "same-batch create+drop renumbers" `Quick