Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 156 additions & 1 deletion Guide/auto-refresh.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,6 @@ action MyAction = do -- <-- We don't enable auto refresh at the action start in
render MyView { expensiveModels, cheap }
```

### Custom SQL Queries with Auto Refresh

Auto Refresh automatically tracks all tables your action is using by hooking itself into the Query Builder and `fetch` functions.

Expand All @@ -124,3 +123,159 @@ action StatsAction = autoRefresh do
```

The [`trackTableRead`](https://ihp.digitallyinduced.com/api-docs/IHP-ModelSupport.html#v:trackTableRead) marks the table as accessed for Auto Refresh and leads to the table being watched.

### Using Auto Refresh with HTMX

HTMX and Auto Refresh work well together:

- HTMX loads/replaces fragments in response to user interactions
- Auto Refresh keeps those fragments up-to-date when database rows change

For pages that use HTMX fragments, include the HTMX-specific Auto Refresh client:

```haskell
scripts :: Html
scripts = [hsx|
<script src={assetPath "/vendor/htmx.min.js"}></script>
<script src={assetPath "/vendor/morphdom-umd.min.js"}></script>
<script src={assetPath "/helpers-htmx.js"}></script>
<script src={assetPath "/ihp-auto-refresh-htmx.js"}></script>
<script src={assetPath "/app.js"}></script>
|]
```

`/helpers-htmx.js` + `/ihp-auto-refresh-htmx.js` is the HTMX equivalent of the classic
`/helpers.js` + `/ihp-auto-refresh.js` setup:

- `helpers-htmx.js` handles HTMX morphdom swaps and helper compatibility (`ihp:load`, `ihp:unload`, date/time formatting, flatpickr init, toggle/back/file preview helpers, alert dismiss on request)
- `ihp-auto-refresh-htmx.js` handles Auto Refresh WebSocket sessions, target-based fragment updates, and pause/resume around HTMX requests

Keep this script order:

1. `htmx.min.js`
2. `morphdom-umd.min.js`
3. `helpers-htmx.js`
4. `ihp-auto-refresh-htmx.js`
5. `app.js`

Use `/ihp-auto-refresh.js` for full-page morphing without HTMX.
Use `/ihp-auto-refresh-htmx.js` when HTMX controls fragment swaps.
Do not include both scripts on the same page.
Also do not include both `/helpers.js` and `/helpers-htmx.js` on the same page.

For HTMX fragment actions, prefer `renderFragment`. It skips the layout and includes the Auto Refresh meta tag.

#### End-to-end example

Let's say we want a project page where comments are loaded by HTMX and then kept live by Auto Refresh.

In the parent page we render a target container with a stable `id`:

```haskell
[hsx|
<div
id="comments-pane"
hx-get={pathTo CommentsFragmentAction { projectId }}
hx-trigger="load"
hx-swap="innerHTML"
>
<div class="text-muted">Loading comments ...</div>
</div>
|]
```

This is the recommended default setup. With a stable target `id`, Auto Refresh can usually infer the correct target automatically.

The fragment action enables Auto Refresh and renders only the fragment content:

```haskell
action CommentsFragmentAction { projectId } = autoRefresh do
comments <- query @Comment
|> filterWhere (#projectId, projectId)
|> orderByDesc #createdAt
|> fetch
renderFragment CommentsFragmentView { .. }
```

The fragment view:

```haskell
instance View CommentsFragmentView where
html CommentsFragmentView { comments } = [hsx|
{forEach comments renderComment}
|]
```

Whenever a comment row changes, Auto Refresh re-runs the fragment action and morphs the target container.

`renderFragment` renders the view without layout and prepends `autoRefreshMeta`, so you usually don't need to include
`{autoRefreshMeta}` manually in HTMX fragment responses.

Write actions can simply return `204` and let Auto Refresh update the fragment:

```haskell
action CreateCommentAction = do
let comment = newRecord @Comment
comment
|> fill @'["projectId", "body"]
|> ifValid \case
Left _ -> respondAndExitWithHeaders (responseLBS status422 [] "")
Right validComment -> do
validComment |> createRecord
respondAndExitWithHeaders (responseLBS status204 [] "")
```

#### Choosing the update target

In practice, a **stable `id`** means:

- The element that receives the HTMX swap has an `id`
- The same `id` is kept across all swaps
- There is only one element with that `id` on the page
- With `hx-swap="innerHTML"`, this `id` is on the outer container (the response fragment itself does not need that `id`)

To keep fragment updates predictable:

1. Prefer a stable `id` on your HTMX target container
2. Keep that `id` unchanged across swaps
3. If no stable `id` is available, Auto Refresh falls back to full-page updates

As long as HTMX swaps HTML into a target element, the common verbs (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`) work the same way.

#### What is handled well

- `hx-swap="innerHTML"` with a target element that has a stable `id`
- `hx-swap="outerHTML"` when the returned element still has the same stable `id`
- Fragments loaded by `hx-get`, and later updated by any HTMX verb, as long as swaps happen into a resolvable target
- Pages with multiple fragments, each with its own target and action

#### Cases to avoid (or configure explicitly)

- No target `id`: updates fall back to full-page morphing
- `hx-swap="none"` (or responses that do not swap HTML): no target can be inferred from the swap
- Changing/removing the target selector over time (for example changing `id` between swaps): updates can stop applying
- Duplicate `id`s for swap targets: update behavior becomes unpredictable

#### Multiple fragments on the same page

You can have multiple independent HTMX + Auto Refresh fragments on one page. Give each fragment:

1. Its own swap target
2. Its own action
3. A stable `id` on the swap target

```haskell
[hsx|
<div id="todo-list-fragment" hx-get={pathTo TodoListFragmentAction} hx-trigger="load" hx-swap="innerHTML"></div>
<div id="activity-fragment" hx-get={pathTo ActivityFragmentAction} hx-trigger="load" hx-swap="innerHTML"></div>
|]
```

Each fragment gets its own Auto Refresh session and updates independently.

#### Common pitfalls

- Do not include both `/ihp-auto-refresh.js` and `/ihp-auto-refresh-htmx.js`
- With `hx-swap="innerHTML"`, return only inner content, not another wrapper with the same `id`
- Keep the target container stable across renders so morphdom can preserve `hx-*` attributes and input state
- If updates affect too much UI, split the page into smaller HTMX fragments or switch to `autoRefreshWith` filtering
148 changes: 144 additions & 4 deletions Guide/htmx-and-hyperscript.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,141 @@ scripts = [hsx|
|]
```

The `helpers.js` script from ihp can interfer with htmx, but ihp provides a `/helpers-htmx.js` and `/ihp-auto-refresh-htmx.js` that are drop in replacement for working with htmx.

### Replacing `helpers.js` with HTMX + morphdom

You can remove `helpers.js` and use HTMX + `helpers-htmx.js` instead.

For boosted `<a>` and `<form>` navigation, set HTMX attributes on the layout root:

1. Add HTMX and morphdom scripts to your layout.
2. Add IHP's HTMX helper script (`/helpers-htmx.js`).
3. Set `hx-boost`, `hx-target`, `hx-select`, and `hx-swap` on your layout root so boosted links/forms patch only the page content container.

```haskell
defaultLayout :: Html -> Html
defaultLayout inner = [hsx|
<!DOCTYPE html>
<html lang="en">
<head>
{metaTags}
{stylesheets}
{scripts}
<title>{pageTitleOrDefault "App"}</title>
</head>
<body
hx-ext="morphdom-swap"
hx-boost="true"
hx-target="#page-content"
hx-select="#page-content"
hx-swap="morphdom"
>
<div id="page-content" class="container mt-4">
{renderFlashMessages}
{inner}
</div>
</body>
</html>
|]
```

```haskell
scripts :: Html
scripts = [hsx|
{when isDevelopment devScripts}
...
<script src={assetPath "/vendor/htmx.min.js"}></script>
<script src={assetPath "/vendor/morphdom-umd.min.js"}></script>
<script src={assetPath "/helpers-htmx.js"}></script>
<script src={assetPath "/ihp-auto-refresh-htmx.js"}></script>
<script src={assetPath "/app.js"}></script>
|]
```

`/helpers-htmx.js` is designed to be used together with `/ihp-auto-refresh-htmx.js`.

- `helpers-htmx.js` provides HTMX morphdom swap behavior and helper compatibility hooks
- `ihp-auto-refresh-htmx.js` provides Auto Refresh session management and fragment updates

Do not mix legacy and HTMX variants on the same page:

- Not together: `/helpers.js` and `/helpers-htmx.js`
- Not together: `/ihp-auto-refresh.js` and `/ihp-auto-refresh-htmx.js`


| Difference | What official extension does | What `helpers-htmx.js` does | Do you need this? |
| --- | --- | --- | --- |
| Input/textarea/option state handling | Relies on default morphdom behavior | Explicitly keeps current client state for `input`, `textarea`, `option` during swaps | Recommended when users can be typing while live updates arrive (Auto Refresh, concurrent updates) |
| File input patch safety | No explicit guard for `input[type=file]` | Skips updating file inputs during morph to avoid losing selected files | Recommended if forms with file uploads can be swapped |
| Node key strategy | No custom `getNodeKey` | Uses stable keys (`id`, script `src`) for better identity matching | Recommended for complex/reordered DOM and script stability |
| HTMX processing after custom swap | Returns swapped nodes to HTMX; no explicit `htmx.process` call in extension source | Calls `htmx.process(target)` explicitly after swap | Defensive behavior; usually safe either way |

Use the official extension if you want minimal behavior and default morphdom semantics.
Use `helpers-htmx.js` if you want a closer replacement for IHP helper behavior with fewer edge-case regressions.
In IHP docs and defaults, `helpers-htmx.js` is the standard morphdom swap path.

The source is available as an IHP built-in static file at `ihp/data/static/helpers-htmx.js`.
If you want to vendor it into your app, copy `${IHP}/static/helpers-htmx.js` into your project's `static/` directory.
If you're using `make static/prod.js` bundling, add `JS_FILES += ${IHP}/static/helpers-htmx.js` to your app `Makefile`.

With this setup, HTMX boost handles `<a>` and `<form>` requests and morphdom keeps DOM identity stable (e.g. input values and cursor position).

If you use Auto Refresh with HTMX, use `/ihp-auto-refresh-htmx.js`.

#### `helpers.js` feature-by-feature mapping

| `helpers.js` feature | HTMX / new script equivalent | Status |
| --- | --- | --- |
| Morphdom page/fragment patching (`transitionToNewPage`) | `hx-swap="morphdom"` + `/helpers-htmx.js` | Covered |
| Stable node matching (`getNodeKey`) | Implemented in `/helpers-htmx.js` (`id`, script `src`) | Covered |
| Preserve input value/cursor/checked/selected during swaps | Implemented inside `/helpers-htmx.js` | Covered |
| HTMX re-processing after morphdom swap | Implemented inside `/helpers-htmx.js` via `htmx.process` | Covered |
| Intercept links/forms and submit via AJAX (`initDisableButtonsOnSubmit` + `submitForm`) | `hx-boost="true"` on layout/body | Covered |
| Per-form opt-out of AJAX (`disableJavascriptSubmission`) | `hx-boost="false"` on the form (or parent scope) | Covered with config |
| Follow submitter semantics (`formAction`, clicked button value) | Standard form submitter behavior with HTMX-boosted forms | Covered |
| File uploads via form submit | Native form multipart behavior (`enctype="multipart/form-data"` or `hx-encoding`) | Covered with config |
| Delete links with confirmation (`.js-delete`, `.js-delete-no-confirm`) | `hx-delete` + `hx-confirm` | Covered |
| Disable submit buttons while request is in-flight | `hx-disabled-elt` | Covered |
| Dismiss `.alert` on submit | Built into `/helpers-htmx.js` (`htmx:beforeRequest`) | Covered |
| Pause auto refresh around requests | `/ihp-auto-refresh-htmx.js` already hooks into `htmx:beforeRequest`/`htmx:afterRequest` | Covered |
| Move/refresh auto-refresh session metadata across HTMX swaps | `/ihp-auto-refresh-htmx.js` handles meta harvesting + target inference | Covered |
| URL/history updates after boosted requests | `hx-push-url="true"` where needed (especially GET filter/search forms) | Covered with config |
| Special modal behavior in body morph (`modal-open` + `#main-row` guard) | Usually avoid by targeting a stable content container (`#page-content`) instead of full-body swaps; use `hx-preserve` for modal roots if needed | App-specific behavior |
| `.js-back` helper | Built into `/helpers-htmx.js` (`.js-back`, `[data-js-back]`) | Covered |
| `[data-toggle]` helper for disabling/enabling other fields | Built into `/helpers-htmx.js` | Covered |
| File upload preview (`input[type="file"][data-preview]`) | Built into `/helpers-htmx.js` | Covered |
| Date/time pretty formatting (`.time-ago`, `.date-time`, `.date`, `.time`) | Built into `/helpers-htmx.js` | Covered |
| Flatpickr auto-init on date fields | Built into `/helpers-htmx.js` | Covered |
| `.js-scroll-into-view` after navigation | Built into `/helpers-htmx.js` | Covered |
| `ihp:load` / `ihp:unload` events | Built into `/helpers-htmx.js` | Covered |
| Timer cleanup utilities (`clearAllIntervals` / `clearAllTimeouts`) | Built into `/helpers-htmx.js` | Covered |

#### Detailed migration recipes

That means you should not need to re-implement these in `app.js`:

1. `.js-back` (and `[data-js-back]`) back button handling
2. `[data-toggle]` dependent field enable/disable behavior
3. `input[type=file][data-preview]` preview behavior
4. Date/time formatting (`.time-ago`, `.date-time`, `.date`, `.time`)
5. Flatpickr auto-initialization on initial load and HTMX swaps
6. `.js-scroll-into-view` behavior after swaps
7. `.alert` dismissal on HTMX form requests
8. `ihp:load` and `ihp:unload` compatibility events

HTMX-native features still use HTMX attributes:

1. Delete links: `hx-delete` (+ `hx-confirm` if needed)
2. Disable submit while requesting: `hx-disabled-elt`
3. Push URL for GET forms: `hx-push-url="true"`
4. Disable boost per form: `hx-boost="false"`
5. Multipart uploads: `enctype="multipart/form-data"` (optionally `hx-encoding="multipart/form-data"`)

#### Notes on remaining gaps

With `helpers-htmx.js` plus HTMX-native attributes (`hx-delete`, `hx-disabled-elt`, `hx-push-url`, etc.), the common `helpers.js` migration surface is covered.

### htmx usage

Assume the simple controller as an example:
Expand All @@ -65,7 +200,12 @@ data CounterController

Add `parseRoute @CounterController` to the list of `instance FrontController WebApplication` in `FrontController.hs` (or you'll get a 404 on calling it), and add an `instance AutoRoute CounterController` to `Routes.hs` (or you'll get a compilation error about it not being an instance of AutoRoute).

Instead of using the `render` function, htmx routes are better used with `respondHtml` to avoid the layout being shipped as part of the response. The same function can be used for initializing the view as well as updating.
For HTMX endpoints, use different render helpers depending on the response:

- Full page responses: use `render`
- Fragment responses: use `respondHtmlFragment` (or `renderFragment` when returning a `View`)

This avoids shipping the layout in fragment responses.

```haskell
module Web.Controller.Counter where
Expand All @@ -85,15 +225,15 @@ instance Controller CounterController where
action IncrementCountAction{counterId} = do
counter <- fetch counterId
updatedCounter <- counter |> incrementField #count |> updateRecord
respondHtml $ counterView updatedCounter
respondHtmlFragment $ counterView updatedCounter

action DecrementCountAction{counterId} = do
counter <- fetch counterId
updatedCounter <- counter |> decrementField #count |> updateRecord
respondHtml $ counterView updatedCounter
respondHtmlFragment $ counterView updatedCounter
```

We define the `CounterView` like this, separating the `counterView` function into a function that can be used be the initial view as well as the updater routes (`IncrementCountAction` and `DecrementCountAction`).
We define the `CounterView` like this, separating `counterView` into a reusable function for both the initial page and the fragment update routes (`IncrementCountAction` and `DecrementCountAction`).

```haskell
module Web.View.Counter.Counter where
Expand Down
Loading