Sfoglia il codice sorgente

feat(continuation): add ContinuationContext with fragment and partial rendering support

Introduce ContinuationContext class to simplify SSE continuation handling
with cleaner APIs for sending fragments and partial updates. Add to_fragment()
method to Component for extracting specific elements by sid. Add prepare_once()
lifecycle method for one-time initialization separate from per-request prepare().

ContinuationContext provides:
- send_fragment(event_name, sid) for sending updated element HTML
- send_string(event_name, content) for raw string content

BREAKING CHANGE: Component.continuation() signature changed from SseStream parameter to ContinuationContext
Billy Barrow 1 settimana fa
parent
commit
486cc9a363

+ 41 - 18
examples/ProgressExample.vala

@@ -22,6 +22,32 @@ using Spry;
  */
 class ProgressComponent : Component {
     
+    private int _percent = 0;
+    private string _status = "Initializing...";
+    
+    public int percent {
+        get { return _percent; }
+        set {
+            _percent = value;
+            var progress_bar = this["progress-bar"];
+            if (progress_bar != null) {
+                progress_bar.set_attribute("style", @"width: $(_percent)%");
+                progress_bar.text_content = @"$(_percent)%";
+            }
+        }
+    }
+    
+    public string status {
+        get { return _status; }
+        set {
+            _status = value;
+            var status_el = this["status"];
+            if (status_el != null) {
+                status_el.text_content = @"Status: $(_status)";
+            }
+        }
+    }
+    
     public override string markup { get {
         return """
         <!DOCTYPE html>
@@ -76,17 +102,17 @@ class ProgressComponent : Component {
         
         <div spry-continuation>
             <div class="progress-container" sse-swap="progress">
-                <div class="progress-bar" id="progress-bar" style="width: 0%">
+                <div class="progress-bar" id="progress-bar" sid="progress-bar" style="width: 0%">
                     0%
                 </div>
             </div>
             
-            <div class="status" sse-swap="status">
+            <div class="status" sse-swap="status" sid="status" hx-swap="outerHTML">
                 <strong>Status:</strong> Initializing...
             </div>
             
             <div class="log" id="log">
-                <div sse-swap="log">Waiting for task to start...</div>
+                <div sse-swap="log" hx-swap="afterbegin">Waiting for task to start...</div>
             </div>
         </div>
         </body>
@@ -101,7 +127,7 @@ class ProgressComponent : Component {
      * The event data should be HTML content that will be swapped into elements
      * with matching sse-swap="eventname" attributes.
      */
-    public async override void continuation(SseStream stream) throws Error {
+    public async override void continuation(ContinuationContext continuation_context) throws Error {
         // Simulate a long-running task with progress updates
         var steps = new string[] {
             "Initializing task...",
@@ -119,20 +145,18 @@ class ProgressComponent : Component {
         };
         
         for (int i = 0; i < steps.length; i++) {
-            // Calculate progress percentage
-            int percent = (int)(((i + 1) / (double)steps.length) * 100);
+            // Update the template
+            percent = (int)(((i + 1) / (double)steps.length) * 100);
+            status = steps[i];
             
             // Send progress bar update - HTML that will be swapped into the progress bar
-            yield stream.send_event(new SseEvent.with_type("progress", 
-                @"<div class=\"progress-bar\" id=\"progress-bar\" style=\"width: $percent%\">$percent%</div>"));
+            yield continuation_context.send_fragment("progress", "progress-bar");
             
             // Send status update - HTML that will be swapped into the status div
-            yield stream.send_event(new SseEvent.with_type("status", 
-                @"<strong>Status:</strong> $(steps[i])"));
+            yield continuation_context.send_fragment("status", "status");
             
             // Send log message - HTML that will be appended to the log
-            yield stream.send_event(new SseEvent.with_type("log", 
-                @"<div class=\"log-entry\">$(steps[i])</div>"));
+            yield continuation_context.send_string("log", @"<div class=\"log-entry\">$(steps[i])</div>");
             
             // Simulate work being done (500ms per step)
             Timeout.add(500, () => {
@@ -143,12 +167,11 @@ class ProgressComponent : Component {
         }
         
         // Send final completion messages
-        yield stream.send_event(new SseEvent.with_type("progress", 
-            "<div class=\"progress-bar\" id=\"progress-bar\" style=\"width: 100%\">100% ✓</div>"));
-        yield stream.send_event(new SseEvent.with_type("status", 
-            "<strong>Status:</strong> Task completed successfully!"));
-        yield stream.send_event(new SseEvent.with_type("log", 
-            "<div class=\"log-entry\">✓ All tasks completed!</div>"));
+        percent = 100;
+        status = "Task completed successfully!";
+        yield continuation_context.send_fragment("progress", "progress-bar");
+        yield continuation_context.send_fragment("status", "status");
+        yield continuation_context.send_string("log", "<div class=\"log-entry\">✓ All tasks completed!</div>");
     }
 }
 

+ 43 - 12
src/Component.vala

@@ -23,10 +23,13 @@ namespace Spry {
         public virtual async void prepare() throws Error {
             // No-op default
         }
+        public virtual async void prepare_once() throws Error {
+            // No-op default
+        }
         public virtual async void handle_action(string action) throws Error {
             // No-op default
         }
-        public virtual async void continuation(SseStream stream) throws Error {
+        public virtual async void continuation(ContinuationContext continuation_context) throws Error {
             // No-op default
         }
         public virtual async void continuation_canceled() throws Error {
@@ -40,6 +43,7 @@ namespace Spry {
         private Dictionary<string, Component> _child_components = new Dictionary<string, Component>();
         private HashSet<Component> _global_sources = new HashSet<Component>();
         private MarkupDocument _instance;
+        private bool _prepare_once_called;
 
         private MarkupDocument instance { get {
             if(_instance == null) {
@@ -130,21 +134,34 @@ namespace Spry {
         }
 
         public async MarkupDocument to_document() throws Error {
+            if(!_prepare_once_called) {
+                yield prepare_once();
+                _prepare_once_called = true;
+            }
             yield prepare();
+
             var final_instance = instance.copy();
+            yield transform_document(final_instance);
+            return final_instance;
+        }
 
-            remove_hidden_blocks(final_instance);
-            yield transform_outlets(final_instance);
-            yield transform_components(final_instance);
-            replace_control_blocks(final_instance);
-            transform_action_nodes(final_instance);
-            transform_target_nodes(final_instance);
-            transform_global_nodes(final_instance);
-            transform_script_nodes(final_instance);
-            transform_continuation_nodes(final_instance);
-            remove_internal_sids(final_instance);
-            yield append_globals(final_instance);
+        public async MarkupDocument to_fragment(string sid) throws Error {
+            if(!_prepare_once_called) {
+                yield prepare_once();
+                _prepare_once_called = true;
+            }
+            yield prepare();
+
+            // Extract the fragment
+            var final_instance = instance.copy();
+            var template_fragment = final_instance.select_one(@"//*[@sid='$sid']")?.outer_html;
+            if(template_fragment == null) {
+                throw new ComponentError.ELEMENT_NOT_FOUND(@"No spry-component with sid '$sid' found.");
+            }
+            final_instance.body.inner_html = template_fragment;
 
+            // Do regular transform
+            yield transform_document(final_instance);
             return final_instance;
         }
 
@@ -179,6 +196,20 @@ namespace Spry {
             return component;
         }
 
+        private async void transform_document(MarkupDocument doc) throws Error {
+            remove_hidden_blocks(doc);
+            yield transform_outlets(doc);
+            yield transform_components(doc);
+            replace_control_blocks(doc);
+            transform_action_nodes(doc);
+            transform_target_nodes(doc);
+            transform_global_nodes(doc);
+            transform_script_nodes(doc);
+            transform_continuation_nodes(doc);
+            remove_internal_sids(doc);
+            yield append_globals(doc);
+        }
+
         private async void transform_outlets(MarkupDocument doc) throws Error {
             var outlets = doc.select("//spry-outlet");
             foreach (var outlet in outlets) {

+ 49 - 0
src/ContinuationContext.vala

@@ -0,0 +1,49 @@
+using Astralis;
+
+namespace Spry {
+
+    public class ContinuationContext : Object {
+
+        public SseStream sse_stream { get; private set;}
+        private Component component;
+
+        public ContinuationContext(Component component, SseStream stream) {
+            this.sse_stream = stream;
+            this.component = component;
+        }
+
+        public async void send_fragment(string event_type, string sid) throws Error {
+            var fragment = yield this.component.to_fragment(sid);
+            yield sse_stream.send_event(new SseEvent.with_type(event_type, fragment.body.inner_html));
+        }
+
+        public async void send_json(string event_type, Json.Node node) throws Error {
+            var json = Json.to_string(node, false);
+            yield sse_stream.send_event(new SseEvent.with_type(event_type, json));
+        }
+
+        public async void send_string(string event_type, string data) throws Error {
+            yield sse_stream.send_event(new SseEvent.with_type(event_type, data));
+        }
+
+        public async void send_full_update(string event_type) throws Error {
+            var doc = yield component.to_document();
+            string data;
+            if(component.get_type().is_a(typeof(PageComponent))) {
+                data = doc.to_html();
+            }
+            else {
+                data = doc.body.inner_html;
+            }
+            yield sse_stream.send_event(new SseEvent.with_type(event_type, data));
+        }
+
+        public async void send_event(SseEvent event) throws Error {
+            yield sse_stream.send_event(event);
+        }
+
+        
+
+    }
+
+}

+ 2 - 1
src/ContinuationProvider.vala

@@ -21,7 +21,8 @@ namespace Spry {
                 }
 
                 try {
-                    yield component.continuation(stream);
+                    var context = new ContinuationContext(component, stream);
+                    yield component.continuation(context);
                 }
                 catch(Error e) {
                     warning(@"Component $(component.get_type().name()) threw exception in follow up: $(e.message)");

+ 1 - 0
src/meson.build

@@ -9,6 +9,7 @@ sources = files(
     'Context.vala',
     'PathProvider.vala',
     'ContinuationProvider.vala',
+    'ContinuationContext.vala',
     'Static/StaticResource.vala',
     'Static/MemoryStaticResource.vala',
     'Static/FileStaticResource.vala',