From 07956e0c180eb188a8938727cbb8a10b59b732b8 Mon Sep 17 00:00:00 2001 From: Christian Tanul Date: Thu, 29 Oct 2026 19:50:28 +0200 Subject: [PATCH 1/2] Handle htmx 4 full-page requests --- fasthtml/core.py | 4 ++-- nbs/api/00_core.ipynb | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/fasthtml/core.py b/fasthtml/core.py index 09c2acb4..be843610 100644 --- a/fasthtml/core.py +++ b/fasthtml/core.py @@ -482,7 +482,7 @@ def respond(req, heads, bdy): def is_full_page(req, resp): "Check if response should be rendered as full page or fragment" if resp and any(getattr(o, 'tag', '')=='html' for o in resp): return True - return 'hx-request' in req.headers and 'hx-history-restore-request' not in req.headers + return 'hx-request' in req.headers and req.headers.get('hx-request-type') != 'full' and 'hx-history-restore-request' not in req.headers # %% ../nbs/api/00_core.ipynb #358badec def _part_resp(req, resp): @@ -491,7 +491,7 @@ def _part_resp(req, resp): resp = resp + tuple(getattr(req, 'injects', ())) http_hdrs,resp = partition(resp, risinstance(HttpHeader)) tasks,resp = partition(resp, risinstance(BackgroundTask)) - kw = {"headers": {"vary": "HX-Request, HX-History-Restore-Request"}} + kw = {"headers": {"vary": "HX-Request, HX-Request-Type, HX-History-Restore-Request"}} if http_hdrs: kw['headers'] |= {o.k:str(o.v) for o in http_hdrs} if tasks: ts = BackgroundTasks() diff --git a/nbs/api/00_core.ipynb b/nbs/api/00_core.ipynb index a5ace4a8..8c347317 100644 --- a/nbs/api/00_core.ipynb +++ b/nbs/api/00_core.ipynb @@ -1570,7 +1570,7 @@ "def is_full_page(req, resp):\n", " \"Check if response should be rendered as full page or fragment\"\n", " if resp and any(getattr(o, 'tag', '')=='html' for o in resp): return True\n", - " return 'hx-request' in req.headers and 'hx-history-restore-request' not in req.headers" + " return 'hx-request' in req.headers and req.headers.get('hx-request-type') != 'full' and 'hx-history-restore-request' not in req.headers" ] }, { @@ -1587,7 +1587,7 @@ " resp = resp + tuple(getattr(req, 'injects', ()))\n", " http_hdrs,resp = partition(resp, risinstance(HttpHeader))\n", " tasks,resp = partition(resp, risinstance(BackgroundTask))\n", - " kw = {\"headers\": {\"vary\": \"HX-Request, HX-History-Restore-Request\"}}\n", + " kw = {\"headers\": {\"vary\": \"HX-Request, HX-Request-Type, HX-History-Restore-Request\"}}\n", " if http_hdrs: kw['headers'] |= {o.k:str(o.v) for o in http_hdrs}\n", " if tasks:\n", " ts = BackgroundTasks()\n", @@ -3133,6 +3133,8 @@ "assert 'FastHTML page' in txt and '

bar

' in txt and '' in txt\n", "\n", "assert cli.get('/xt2', **hxhdr).text.strip() == '

bar

'\n", + "r = cli.get('/xt2', headers={'hx-request':\"1\", 'hx-request-type':\"full\"})\n", + "assert '' in r.text and 'HX-Request-Type' in r.headers['vary']\n", "\n", "@rt('/xt3')\n", "def get(): return Html(Head(Title('hi')), Body(P('there')))\n", From 128580b450b2af98a1f9da1b3a48b30cccf15ef4 Mon Sep 17 00:00:00 2001 From: Christian Tanul Date: Thu, 29 Oct 2026 21:41:52 +0200 Subject: [PATCH 2/2] Cover htmx 4 partial requests --- fasthtml/core.py | 2 +- nbs/api/00_core.ipynb | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/fasthtml/core.py b/fasthtml/core.py index be843610..875ceb75 100644 --- a/fasthtml/core.py +++ b/fasthtml/core.py @@ -16,7 +16,7 @@ class Cards: ... # class route group: each verb-named method (get, patch, . A('More', href=foo.to(a=1)) # `.to()` fills path params; the rest become query params. Div('...', hx_get=foo) # route functions work directly as htmx attr values: hx-get="/foo" -Handlers return FT trees (from `fastcore.xml`), rendered as HTML automatically: a full page for regular requests, a bare fragment for HTMX requests. In a full page, top-level `Title`, `Meta`, `Link`, and `Style` items go in ``; the rest goes in ``. Strings are sent as HTML, dicts as JSON, and a Starlette `Response` is sent untouched. A return tuple may mix content with special items: any `HttpHeader` (e.g. from `cookie()` or `HtmxResponseHeaders()`) becomes a response header, any `BackgroundTask` runs after the response is sent, and the rest renders as content. `Redirect` picks the right redirect mechanism for HTMX vs regular requests, and `FtResponse` wraps FT content when you need the status code or headers. +Handlers return FT trees (from `fastcore.xml`), rendered as HTML automatically: a full page for regular requests and htmx 4 `full` requests, a bare fragment for other HTMX requests. In a full page, top-level `Title`, `Meta`, `Link`, and `Style` items go in ``; the rest goes in ``. Strings are sent as HTML, dicts as JSON, and a Starlette `Response` is sent untouched. A return tuple may mix content with special items: any `HttpHeader` (e.g. from `cookie()` or `HtmxResponseHeaders()`) becomes a response header, any `BackgroundTask` runs after the response is sent, and the rest renders as content. `Redirect` picks the right redirect mechanism for HTMX vs regular requests, and `FtResponse` wraps FT content when you need the status code or headers. Handler parameters are filled from the request: each is looked up in path, cookie, header (snake_case names match Hyphen-Case headers), query, then form data, and cast by calling its annotation on the value. Hyphens in form/query names match underscores in handler parameters and typed body fields. Exact names take precedence within each source. Dictionary and request access retain the original keys. `bool`, `int`, and `date` get smart string parsing; `UploadFile` passes through. Repeated params take the last value unless annotated `list[T]`, which collects them all. A param with no annotation and no special name is ignored, with a warning. A dataclass, TypedDict, namedtuple, or any annotated class collects the whole form body; a `__from_request__` classmethod customizes construction. A missing required param is a 400, a failed cast a 404. These special names need no annotation: diff --git a/nbs/api/00_core.ipynb b/nbs/api/00_core.ipynb index 8c347317..7ebb37ba 100644 --- a/nbs/api/00_core.ipynb +++ b/nbs/api/00_core.ipynb @@ -41,7 +41,7 @@ " A('More', href=foo.to(a=1)) # `.to()` fills path params; the rest become query params.\n", " Div('...', hx_get=foo) # route functions work directly as htmx attr values: hx-get=\"/foo\"\n", "\n", - "Handlers return FT trees (from `fastcore.xml`), rendered as HTML automatically: a full page for regular requests, a bare fragment for HTMX requests. In a full page, top-level `Title`, `Meta`, `Link`, and `Style` items go in ``; the rest goes in ``. Strings are sent as HTML, dicts as JSON, and a Starlette `Response` is sent untouched. A return tuple may mix content with special items: any `HttpHeader` (e.g. from `cookie()` or `HtmxResponseHeaders()`) becomes a response header, any `BackgroundTask` runs after the response is sent, and the rest renders as content. `Redirect` picks the right redirect mechanism for HTMX vs regular requests, and `FtResponse` wraps FT content when you need the status code or headers.\n", + "Handlers return FT trees (from `fastcore.xml`), rendered as HTML automatically: a full page for regular requests and htmx 4 `full` requests, a bare fragment for other HTMX requests. In a full page, top-level `Title`, `Meta`, `Link`, and `Style` items go in ``; the rest goes in ``. Strings are sent as HTML, dicts as JSON, and a Starlette `Response` is sent untouched. A return tuple may mix content with special items: any `HttpHeader` (e.g. from `cookie()` or `HtmxResponseHeaders()`) becomes a response header, any `BackgroundTask` runs after the response is sent, and the rest renders as content. `Redirect` picks the right redirect mechanism for HTMX vs regular requests, and `FtResponse` wraps FT content when you need the status code or headers.\n", "\n", "Handler parameters are filled from the request: each is looked up in path, cookie, header (snake_case names match Hyphen-Case headers), query, then form data, and cast by calling its annotation on the value. Hyphens in form/query names match underscores in handler parameters and typed body fields. Exact names take precedence within each source. Dictionary and request access retain the original keys. `bool`, `int`, and `date` get smart string parsing; `UploadFile` passes through. Repeated params take the last value unless annotated `list[T]`, which collects them all. A param with no annotation and no special name is ignored, with a warning. A dataclass, TypedDict, namedtuple, or any annotated class collects the whole form body; a `__from_request__` classmethod customizes construction. A missing required param is a 400, a failed cast a 404. These special names need no annotation:\n", "\n", @@ -1555,8 +1555,8 @@ "id": "cfae0a56", "metadata": {}, "source": [ - "Render fragment if `HX-Request` header is _present_ and\n", - " `HX-History-Restore-Request` header is _absent._" + "Render a fragment if `HX-Request` is present, `HX-Request-Type` is not `full`,\n", + "and `HX-History-Restore-Request` is absent." ] }, { @@ -3133,6 +3133,7 @@ "assert 'FastHTML page' in txt and '

bar

' in txt and '' in txt\n", "\n", "assert cli.get('/xt2', **hxhdr).text.strip() == '

bar

'\n", + "assert cli.get('/xt2', headers={'hx-request':\"1\", 'hx-request-type':\"partial\"}).text.strip() == '

bar

'\n", "r = cli.get('/xt2', headers={'hx-request':\"1\", 'hx-request-type':\"full\"})\n", "assert '' in r.text and 'HX-Request-Type' in r.headers['vary']\n", "\n",