Skip to content

Refreshable views and commands

Golden-path FastAPI interaction API: @app.refreshable for GET fragments and @app.command for CSRF-backed mutations. Handles compile to the existing region / InteractionResult stack. Prefer these decorators for new pages; keep Interaction for explicit HTMX allowlists.

from hedron import Hedron, Page, refresh

app = Hedron(...)


@app.refreshable
def status():
    return StatusPanel(...)


@app.command
def restart_service():
    restart()
    return refresh(status).toast("Service restarted")


@app.page("/")
def home():
    return Page(
        status(),
        status.refresh_button("Refresh"),
        restart_service.button("Restart"),
    )

The low-level region, selector, and InteractionResult APIs remain available when you need them. They are not the first-app path.

API level

These symbols are beta. Existing Interaction APIs remain the shipped low-level interface.

Shipped symbols

These 0.43 handles shipped on the 0.42 Web Component platform predecessor (D-071); 0.50 adds ActionHandle.effect / .after success compilation (see Interaction API).

Symbol Package Role
Hedron.refreshable hedron Register a GET renderer and return a FragmentHandle.
Hedron.command hedron Register a mutation and return an ActionHandle.
FragmentHandle hedron Callable mounted-view, route, target, control, patch, and inspection handle.
BoundFragment hedron Parameter-bound fragment instance with a stable instance identity.
FragmentHost hedron-core / hedron Stable semantic wrapper for initial and refreshed content.
ActionHandle hedron Typed command reference for buttons, forms, tests, and inspection.
Refresh hedron Native control targeting a fragment handle.
refresh hedron Build a bounded refresh intent for one or more views.
Patch hedron-core / hedron Direct update for one registered target.
PatchSet hedron-core / hedron One primary plus ordered secondary direct updates.
patches hedron Ergonomic PatchSet builder.

Final import placement is hedron (Hedron.refreshable, Hedron.command, FragmentHandle, ActionHandle, refresh).

Hedron.refreshable

Conceptual signatures:

@app.refreshable
def view(...) -> NodeLike: ...

@app.refreshable(
    path: str | None = None,
    *,
    key: str | None = None,
    name: str | None = None,
    host: FragmentHost | None = None,
    loading: NodeLike | None = None,
    error: NodeLike | str | None = None,
    fallback: str | None = None,
    include_in_schema: bool = False,
    dependencies: Sequence[Depends] | None = None,
) -> Callable[[Callable[P, ContentT]], FragmentHandle[Mapping[str, object], ContentT]]: ...

The first FragmentHandle type slot is deliberately the bind-input type, not the renderer's ParamSpec. In 0.43 it is coarse Mapping[str, object]; 0.44 may specialize it to a Pydantic model without changing generic arity. The original callable signature remains available through renderer inspection.

Parameters

Parameter Meaning
path Optional explicit route. Omitted paths are generated, internal, mount-aware, and not external compatibility promises.
key Optional stable DOM/application identity. Omitted keys derive deterministically from the logical route id.
name Registry and reverse-route name; defaults to the renderer name.
host Semantic tag and safe host attributes shared by initial and refresh rendering.
loading Optional visible loading content; previous useful content remains available by default.
error Optional safe failure content. It does not replace server-side error handling.
fallback Full-page URL for controls that claim no-JavaScript progressive enhancement.
include_in_schema Generated/internal routes are hidden by default.
dependencies Ordinary FastAPI dependencies applied when the view route runs.

Returns

The decorator returns a FragmentHandle while registering the original renderer as a component route. The handle preserves the renderer and its inspection metadata. Calling the handle takes no renderer/dependency arguments and returns a mounted host; parameterized views use bind(...) first. The renderer's dependencies run only on the registered GET route.

Errors

Situation Result
Unsafe or duplicate explicit key Registration error with a stable HED-VIEW-* diagnostic.
Duplicate unbound mount in one page Development/build diagnostic; production render fails safely before duplicate ids are emitted.
Missing renderer argument Normal Python/signature error with handle/route context.
Conflicting client HX-Target HTTP 403 through the existing target disagreement policy.
Direct navigation without a configured full-page fallback Fragment response; controls must not claim progressive enhancement.

FragmentHandle

Conceptual members:

class FragmentHandle(Generic[BindT, ContentT]):
    logical_id: str
    name: str
    path: str
    method: Literal["GET"]
    dom_id: str
    selector: str               # inspectable compatibility detail
    region: FragmentRegion      # low-level compatibility value
    ref: ComponentRef
    renderer: Callable[..., ContentT]
    renderer_signature: Signature

    def __call__(self) -> FragmentHost: ...
    def bind(self, **parameters: object) -> BoundFragment[ContentT]: ...
    def refresh_button(self, label: str = "Refresh", **kwargs: object) -> Refresh: ...
    def replace(self, content: ContentT, **kwargs: object) -> Patch[ContentT]: ...
    def update(self, content: ContentT, **kwargs: object) -> Patch[ContentT]: ...

selector and region exist for inspection and advanced interoperation; beginner examples do not copy them into markup or route policy. The two generic slots and their order are fixed by 0.43; their precision may improve in 0.44 without changing the runtime class.

Bound fragments

card = user_card.bind(user_id=user.id)

Page(card(), card.refresh_button())

0.43 binding is structural. It must:

  • accept only registered bindable path/query names and validate required/extra parameters;
  • resolve path parameters and encode query parameters through safe URL helpers;
  • derive a deterministic instance id without exposing secret values;
  • preserve the base handle's app ownership and policy;
  • reject unresolved parameters before render;
  • allow the same bound object to drive mounting, controls, patches, tests, and diagnostics.

It does not call dependency injection or perform full Pydantic/domain validation. The normal GET route remains authoritative and may return its ordinary validation error. The 0.44 model adapter may add eager validation for explicitly modeled views through the same binding protocol.

Two instances with the same canonical binding have the same identity. Authors mounting the same binding twice must provide an explicit instance key or restructure the page.

Fragment hosts

The default host is neutral and owns interaction state. A conceptual configuration is:

FragmentHost(
    tag="section",
    role="status",
    aria_live="polite",
    attrs={"class": "status-panel"},
)

Only safe ordinary HTML/ARIA attributes are accepted. The host must preserve its tag, attributes, identity, focus contract, and accessible name across initial and replacement renders. The framework does not add role="status" or a landmark automatically.

Refresh controls

Equivalent forms:

status.refresh_button("Refresh")
Refresh(status, label="Refresh")

Refresh renders a native control and derives its URL, target, swap, synchronization, indicator, and fallback behavior from the handle. An explicit caller override may narrow presentation but may not redirect the control to a different unregistered target.

Commands and ActionHandle

Conceptual signatures:

@app.command
def save_note(...): ...

@app.command(
    path: str | None = None,
    *,
    method: str = "POST",
    name: str | None = None,
    fallback: str | None = None,
    include_in_schema: bool = False,
    dependencies: Sequence[Depends] | None = None,
) -> Callable[[Callable[P, ResultT]], ActionHandle[Mapping[str, object], ResultT]]: ...

ActionHandle has the same fixed two-slot convention: command input, then result. Phase 0.43 uses a coarse mapping input; 0.44 may specialize it to a FormBody model.

ActionHandle provides at least:

save_note.button("Save")
Form(action=save_note, ...)
scenario.run(save_note, note="Hello")

Conceptual members:

class ActionHandle(Generic[InputT, ResultT]):
    logical_id: str
    name: str
    path: str
    method: str
    result_type: object
    handler: Callable[..., ResultT]
    handler_signature: Signature

    def button(self, label: str, **kwargs: object) -> NodeLike: ...

Commands default to POST. Unsafe methods follow the active CSRF strategy; action handles do not embed or bypass application authorization. Generated routes are hidden from OpenAPI by default.

Explicit forms in 0.43

Form(action=save_note, ...) still requires explicit fields/controls. The handle supplies the registered URL, method, CSRF/fallback integration, identity, and testing metadata. Phase 0.43 does not expose ActionHandle.form() or infer fields from annotations. Those capabilities begin in 0.44 only for an explicit FormBody model.

refresh

return refresh(status)
return refresh(notes, note_count).toast("Saved")

refresh returns a typed refresh-intent result. After a successful enhanced command response, each mounted target performs its normal GET route. This preserves route dependencies and avoids invoking FastAPI dependency injection from application code.

Requirements:

  • targets are registered handles or bound fragments from the active app;
  • duplicate targets are coalesced in deterministic order;
  • target count and serialized event size are bounded;
  • disconnected or absent targets do nothing safely;
  • host request synchronization prevents an unbounded queue;
  • non-HTMX requests follow the command fallback/redirect path;
  • a refresh intent is not a business transaction or cache invalidation protocol.

Direct patches

return status.replace(StatusPanel(...))

return patches(
    notes.replace(notes_panel()),
    note_count.update(count_panel()),
    toast="Saved",
)

replace uses outerHTML; update uses innerHTML. patches uses its first positional patch as the primary response and later patches as ordered OOB updates.

Conceptual types:

@dataclass(frozen=True, slots=True)
class Patch(Generic[ContentT]):
    target: FragmentHandle | BoundFragment
    content: ContentT
    swap: Literal["outerHTML", "innerHTML"]


@dataclass(frozen=True, slots=True)
class PatchSet:
    primary: Patch
    secondary: tuple[Patch, ...] = ()
    status_code: int = 200
    toast: NodeLike | str | None = None
    cache: CacheHint | None = "vary-htmx"

Additional typed InteractionResult fields may be exposed where they retain identical validation. Arbitrary headers, selector strings, and untyped event JavaScript stay on the advanced API.

Patch errors

Situation Result
No primary patch Construction error.
Duplicate target Construction error; never emit two mechanisms for one target.
Foreign-app or unregistered handle Authorization/contract error before render.
Unbound parameterized handle Binding error.
OOB content with status 204 Rejected like InteractionResult.
Unsafe/unknown swap Construction error.
Content exceeds existing render/payload bounds Existing rendering/response failure.

Testing API

Planned AppScenario additions:

scenario.refresh(status)
scenario.expect(status).to_contain("Healthy")
scenario.run(save_note, note="Hello")
scenario.expect(notes).to_contain("Hello")
scenario.expect_refreshes(notes, note_count)
scenario.expect_patch(note_count, swap="innerHTML")

Assertions resolve route, target, and instance identity through handles. Existing raw request, fragment, header, OOB, and selector assertions remain supported.

Compatibility layer

High-level operation Existing low-level translation
Refreshable handle Component route + FragmentRegion + ComponentRef
Mounted view Stable id host + HTMX attributes
Refresh control RefreshButton/native control with derived target and route
Direct patch InteractionResult primary content and canonical region_id/retarget
Secondary patch Authorized OobUpdate
Refresh intent Typed bounded HTMX trigger consumed by registered hosts
Command Existing action route, CSRF policy, and response conversion

Base handle descriptor and 0.44 extensions

Runtime, Explorer, CLI, AppScenario, and adapters consume one versioned base handle descriptor. It records handle kind, logical/app identity, route/method, host/target/output mechanics, structural binding plan, fallback, limits, stability, and extension namespaces. The base descriptor is authoritative; tools must not re-inspect the handler to reconstruct these facts independently.

In 0.43, command effect knowledge is one of:

  • dynamic: possible targets are unknown until execution;
  • observed: a development trace recorded actual targets, but this is not a declaration.

Phase 0.44 may attach a versioned redacted TypeSchema extension and change effect knowledge to declared. The extension references the base descriptor fingerprint and may narrow validation or improve tooling. It cannot override the route, app ownership, target authority, host, fallback, or response conversion. Unknown extension namespaces do not alter base behavior.

The 0.43 binding adapter performs structural binding. Its protocol is the only supported extension point for the 0.44 Pydantic adapter; consumers do not create a second binding path.

Stability

All new 0.43 symbols begin at beta. Existing region and interaction symbols keep their current stability. Stable promotion requires an explicit inventory update and every phase gate Verified; documentation preference alone does not change API stability.

See also