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 dynamic section updates to the client
        yield continuation_context.send_dynamic("progress-bar");
        yield continuation_context.send_dynamic("status");
        
        Timeout.add(500, () => {
            continuation.callback();
            return false;
        });
        yield;
    }
    
    status = "Complete!";
    yield continuation_context.send_dynamic("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_dynamic(name) Send a dynamic section (by spry-dynamic name) as an SSE event
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 for its children:

public override string markup { get {
    return """
    <div spry-continuation>
        <div class="progress-container" spry-dynamic="progress-bar">
            <div class="progress-bar" spry-unique
                 content-expr='format("%i%%", this.percent)' 
                 style-width-expr='format("%i%%", this.percent)'>
                0%
            </div>
        </div>
        <div class="status" spry-dynamic="status">
            <strong>Status:</strong> <span spry-unique content-expr="this.status">Initializing...</span>
        </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 spry-dynamic Attribute

Use spry-dynamic="name" on child elements to mark them as updatable sections:

<div class="progress-container" spry-dynamic="progress-bar">...</div>
<div class="status" spry-dynamic="status">...</div>

Requirements:

  • Must be a child of an element with spry-continuation
  • Automatically gets sse-swap="_spry-dynamic-{name}" and hx-swap="outerHTML"

When continuation_context.send_dynamic("progress-bar") is called:

  1. The element with spry-dynamic="progress-bar" is located
  2. Its HTML content is rendered and sent as an SSE event
  3. HTMX swaps it into the matching element on the client

The spry-unique Attribute

Use spry-unique to generate unique IDs for elements that need stable targeting:

<div class="progress-bar" spry-unique>...</div>

Restrictions:

  • Cannot specify an id attribute on the same element
  • Cannot be used inside spry-per-* loops (or children of loops)

The generated ID format is _spry-unique-{counter}-{instance_id}.

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