important-details.md 17 KB

Spry Framework - Important Details

Component Basics

Template DOM is NOT Persisted

  • The template is fresh on every request
  • Any state set via setters (like title, completed) is NOT available in handle_action()
  • Always use prepare() to fetch data from store and set up the template

The prepare() Method

  • Called automatically before serialization
  • Use this to fetch data from stores and populate the template
  • Centralizes data fetching logic in one place

    public override async void prepare() throws Error {
    var item = store.get_by_id(_item_id);
    if (item == null) return;
        
    this["title"].text_content = item.title;
    this["button"].text_content = item.completed ? "Undo" : "Done";
    }
    

The prepare_once() Method

  • Called only once before the first prepare() call
  • Useful for one-time initialization that should not repeat on every render
  • Runs before prepare() in the same request lifecycle

    public override async void prepare_once() throws Error {
    // One-time setup, e.g., loading initial data
    initial_data = yield fetch_initial_data();
    }
    

The handle_action() Method

  • Called when HTMX requests trigger an action
  • Modify state in stores, then let prepare() handle template updates
  • For delete with hx-swap="delete", just return (no content needed)

    public async override void handle_action(string action) throws Error {
    var id = get_id_from_query_params();
    _item_id = id; // Set for prepare()
        
    switch (action) {
        case "Toggle":
            store.toggle(id);
            break; // prepare() will be called automatically
        case "Delete":
            store.remove(id);
            return; // HTMX removes element via hx-swap="delete"
    }
    }
    

Real-Time Updates with Continuations (SSE)

Overview

The continuation feature allows a Component to send real-time updates to the client via Server-Sent Events (SSE). This is useful for:

  • Long-running task progress reporting
  • Real-time status updates
  • Live data streaming

The continuation() Method

Override continuation(ContinuationContext context) to send SSE events:

public async override void continuation(ContinuationContext continuation_context) throws Error {
    for (int i = 0; i <= 100; i += 10) {
        percent = i;
        status = @"Processing... $(i)%";
        
        // Send fragment updates to the client
        yield continuation_context.send_fragment("progress", "progress-bar");
        yield continuation_context.send_fragment("status", "status");
        
        Timeout.add(500, () => {
            continuation.callback();
            return false;
        });
        yield;
    }
    
    status = "Complete!";
    yield continuation_context.send_fragment("status", "status");
}

The continuation_canceled() Method

Called when the client disconnects from the SSE stream:

public async override void continuation_canceled() throws Error {
    // Clean up resources when client disconnects
    cleanup_task();
}

ContinuationContext API

Method Description
send_fragment(event_type, sid) Send a fragment (by sid) as an SSE event with the given event type
send_json(event_type, node) Send JSON data as an SSE event
send_string(event_type, data) Send raw string data as an SSE event
send_full_update(event_type) Send the entire component document
send_event(event) Send a custom SseEvent

The spry-continuation Attribute

Add spry-continuation to an element to enable SSE:

public override string markup { get {
    return """
    <div spry-continuation>
        <div class="progress-bar" sid="progress-bar" sse-swap="progress">
            0%
        </div>
        <div class="status" sid="status" sse-swap="status">
            Initializing...
        </div>
    </div>
    """;
}}

The spry-continuation attribute is shorthand for:

  • hx-ext="sse" - Enable HTMX SSE extension
  • sse-connect="(auto-generated-endpoint)" - Connect to the SSE endpoint
  • sse-close="_spry-close" - Close event name

The sse-swap Attribute

Use sse-swap="eventname" on child elements to specify which SSE event type should swap the content:

<div sid="progress-bar" sse-swap="progress">...</div>
<div sid="status" sse-swap="status">...</div>

When continuation_context.send_fragment("progress", "progress-bar") is called, the fragment with sid="progress-bar" is sent as an SSE event with type "progress", and HTMX swaps it into the element listening for that event type.

Required Scripts for SSE

Include the HTMX SSE extension in your markup:

<script spry-res="htmx.js"></script>
<script spry-res="htmx-sse.js"></script>

HTMX Integration

Declarative Attributes

spry-action - Declare HTMX Actions

  • spry-action=":ActionName" - Action on same component (e.g., :Toggle)
  • spry-action="ComponentName:ActionName" - Action on different component (cross-component)

spry-target - Scoped Targeting (within same component)

  • spry-target="sid" - Targets element by its sid attribute
  • Only works within the SAME component
  • Automatically generates proper hx-target with unique IDs

hx-target - Global Targeting (cross-component)

  • Use hx-target="#id" to target elements anywhere on the page
  • Requires the target element to have an id attribute (not just sid)

hx-swap - Swap Strategies

  • hx-swap="outerHTML" - Replace entire target element (prevents nesting)
  • hx-swap="delete" - Remove target element (no response content needed)

Out-of-Band Swaps with spry-global and add_globals_from()

Update multiple elements on the page with a single response using spry-global:

// 1. Add spry-global to elements that may need OOB updates
// 2. Use prepare() to fetch data from store - no manual property setting needed!

class HeaderComponent : Component {
    private TodoStore todo_store = inject<TodoStore>();
    
    public override string markup { get {
        return """
        <div class="card" id="header" spry-global="header">
            <h1>Todo App</h1>
            <div sid="total"></div>
        </div>
        """;
    }}
    
    // prepare() fetches data from store automatically
    public override async void prepare() throws Error {
        this["total"].text_content = todo_store.count().to_string();
    }
}

// 3. Inject the component and pass it to add_globals_from()

class ItemComponent : Component {
    private HeaderComponent header = inject<HeaderComponent>();
    
    public override string markup { get {
        return """
        <div sid="item">...</div>
        """;
    }}
    
    public async override void handle_action(string action) throws Error {
        store.toggle(id);
        // Add header globals for OOB swap (header's prepare() fetches stats)
        add_globals_from(header);
    }
}

The spry-global="key" attribute:

  • Automatically adds hx-swap-oob="true" when the element appears in a response
  • HTMX finds the existing element on the page and swaps it in place
  • Use add_globals_from(component) to append spry-global elements from another component
  • The component's prepare() method is called automatically to populate data

Passing Data with hx-vals

Since template DOM is not persisted, use hx-vals to pass data:

// In prepare() - set on parent element, inherited by children
this["item"].set_attribute("hx-vals", @"{\"id\":$_item_id}");

// In handle_action()
var id_str = http_context.request.query_params.get_any_or_default("id");
var id = int.parse(id_str);

Note: hx-vals is inherited by child elements, so set it on the parent div rather than individual buttons.

Declarative Expression Attributes

Expression Attributes (*-expr)

Use *-expr attributes to dynamically set any attribute based on component properties:

class ProgressComponent : Component {
    public int percent { get; set; }
    
    public override string markup { get {
        return """
        <div class="progress-bar" 
             content-expr='format("%i%%", this.percent)' 
             style-width-expr='format("%i%%", this.percent)'>
            0%
        </div>
        """;
    }}
}

Expression attribute patterns:

  • content-expr="expression" - Set text/HTML content
  • class-expr="expression" - Set CSS classes
  • style-expr="expression" - Set inline styles (e.g., style-width-expr)
  • any-attr-expr="expression" - Set any attribute dynamically

Conditional Class Expressions

For class-* attributes, boolean expressions add/remove the class:

<div class-completed-expr="this.is_completed">Item</div>

If this.is_completed is true, the completed class is added.

Conditional Rendering with spry-if

Use spry-if, spry-else-if, and spry-else for conditional rendering:

<div spry-if="this.is_admin">Admin Panel</div>
<div spry-else-if="this.is_moderator">Moderator Panel</div>
<div spry-else>User Panel</div>

Loop Rendering with spry-per-*

Use spry-per-{varname}="expression" to iterate over collections:

<div spry-per-task="this.tasks">
    <span content-expr="task.name"></span>
</div>

The variable name after spry-per- (e.g., task) becomes available in nested expressions.

Static Resources with spry-res

Use spry-res to reference Spry's built-in static resources:

<script spry-res="htmx.js"></script>
<script spry-res="htmx-sse.js"></script>

This resolves to /_spry/res/{resource-name} automatically.

IoC Composition

Registration Patterns

// State as singleton
application.add_singleton<AppState>();

// Factory as scoped
application.add_scoped<ComponentFactory>();

// Components as transient
application.add_transient<MyComponent>();

Dependency Injection

Use inject<T>() in field initializers:

class MyComponent : Component {
    private TodoStore store = inject<TodoStore>();
    private ComponentFactory factory = inject<ComponentFactory>();
    private HttpContext http_context = inject<HttpContext>();
}

Creating Components

Use ComponentFactory.create<T>() (not inject<T>() which only works in field initializers):

var child = factory.create<ChildComponent>();
child.item_id = item.id;
items.add(child);

Common Patterns

List with Items Pattern

// Parent component
class ListComponent : Component {
    public void set_items(Enumerable<Renderable> items) {
        set_outlet_children("items", items);
    }
    
    public override string markup { get {
        return """
        <div id="my-list" sid="my-list">
            <spry-outlet sid="items"/>
        </div>
        """;
    }}
}

// Item component with prepare()
class ItemComponent : Component {
    private TodoStore store = inject<TodoStore>();
    private HttpContext http_context = inject<HttpContext>();
    private HeaderComponent header = inject<HeaderComponent>();
    
    private int _item_id;
    public int item_id { set { _item_id = value; } }
    
    public override string markup { get {
        return """
        <div class="item" sid="item">
            <span sid="title"></span>
            <button sid="toggle-btn" spry-action=":Toggle" spry-target="item" hx-swap="outerHTML"></button>
        </div>
        """;
    }}
    
    public override void prepare() throws Error {
        var item = store.get_by_id(_item_id);
        this["title"].text_content = item.title;
        // hx-vals on parent is inherited by children
        this["item"].set_attribute("hx-vals", @"{\"id\":$_item_id}");
    }
    
    public async override void handle_action(string action) throws Error {
        var id = int.parse(http_context.request.query_params.get_any_or_default("id"));
        _item_id = id;
        
        if (action == "Toggle") {
            store.toggle(id);
            // Add header globals for OOB swap (header's prepare() fetches stats)
            add_globals_from(header);
        }
    }
}

// Creating the list
var items = new Series<Renderable>();
store.all().iterate((item) => {
    var component = factory.create<ItemComponent>();
    component.item_id = item.id; // Only set ID, prepare() handles rest
    items.add(component);
});
list.set_items(items);

Cross-Component Action Pattern

// Form in one component triggers action on another
<form spry-action="ListComponent:Add" hx-target="#my-list" hx-swap="outerHTML">
    <input name="title"/>
    <button type="submit">Add</button>
</form>

// ListComponent handles the Add action
class ListComponent : Component {
    public async override void handle_action(string action) throws Error {
        if (action == "Add") {
            var title = http_context.request.query_params.get_any_or_default("title");
            store.add(title);
        }
        // Rebuild list...
    }
}

HTML Escaping

<pre> and <code> tags do NOT escape their contents. Manually escape:

// Wrong
<pre><button spry-action=":Toggle">Click</button></pre>

// Correct
<pre>&lt;button spry-action=":Toggle"&gt;Click&lt;/button&gt;</pre>

SpryModule

Automatically registers ComponentEndpoint at /_spry/{component-id}/{action}:

application.add_module<SpryModule>();

This enables the declarative spry-action attributes to work without manual endpoint registration.

Declarative Child Components with <spry-component>

When to Use <spry-component> vs <spry-outlet>

  • <spry-component name="ComponentName" sid="..."/> - For single, known child components
  • <spry-outlet sid="..."/> - For dynamic lists (multiple items from data)

Declaring Child Components

Use <spry-component> in markup for declarative composition:

class TodoPage : PageComponent {
    public override string markup { get {
        return """
        <!DOCTYPE html>
        <html>
        <body>
            <spry-component name="HeaderComponent" sid="header"/>
            <spry-component name="TodoListComponent" sid="todo-list"/>
            <spry-component name="AddFormComponent" sid="add-form"/>
            <spry-component name="FooterComponent" sid="footer"/>
        </body>
        </html>
        """;
    }}
}

Accessing Child Components with get_component_child<T>()

Use get_component_child<T>(sid) in prepare() to access and configure child components:

class TodoPage : PageComponent {
    private TodoStore todo_store = inject<TodoStore>();
    private ComponentFactory factory = inject<ComponentFactory>();
    
    public override async void prepare() throws Error {
        // Get child component and configure it
        var todo_list = get_component_child<TodoListComponent>("todo-list");
        
        // Populate the list (which still uses spry-outlet for dynamic items)
        var items = new Series<Renderable>();
        todo_store.all().iterate((item) => {
            var component = factory.create<TodoItemComponent>();
            component.item_id = item.id;
            items.add(component);
        });
        todo_list.set_items(items);
    }
}

PageComponent - Combining Component and Endpoint

PageComponent is a base class that acts as both a Component AND an Endpoint. This eliminates the need for separate endpoint classes:

// Before: Separate endpoint class
class HomePageEndpoint : Object, Endpoint {
    private ComponentFactory factory = inject<ComponentFactory>();
    
    public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
        var page = factory.create<PageLayoutComponent>();
        return yield page.to_result();
    }
}

// After: PageComponent handles both roles
class TodoPage : PageComponent {
    public override string markup { get { return "..."; } }
    public override async void prepare() throws Error { /* ... */ }
}

SpryConfigurator - Component and Page Registration

Use SpryConfigurator to register components and pages in a structured way:

// Get the configurator
var spry_cfg = application.configure_with<SpryConfigurator>();

// Register child components (transient lifecycle)
spry_cfg.add_component<HeaderComponent>();
spry_cfg.add_component<TodoListComponent>();
spry_cfg.add_component<TodoItemComponent>();
spry_cfg.add_component<AddFormComponent>();
spry_cfg.add_component<FooterComponent>();

// Register pages (scoped lifecycle, acts as Endpoint)
spry_cfg.add_page<TodoPage>(new EndpointRoute("/"));

// Other endpoints still use add_endpoint
application.add_endpoint<TodoJsonEndpoint>(new EndpointRoute("/api/todos"));

Registration Methods

Method Lifecycle Use Case
add_component<T>() Transient Child components created via factory
add_page<T>(route) Scoped Page components that act as endpoints
add_template<T>(prefix) Transient Page templates for layout wrapping