From 4c6f8f33847726fe35aa89e97515e53fad1ecd0e Mon Sep 17 00:00:00 2001 From: Dien Hoa Date: Mon, 30 Mar 2026 19:36:26 +0000 Subject: [PATCH 01/19] migrate all htmx v2 -> v4 from fasthtml-examples --- fasthtml/_modidx.py | 1 + fasthtml/components.py | 30 ++++++++----- fasthtml/core.py | 33 ++++++++++---- fasthtml/fastapp.py | 4 +- fasthtml/pico.py | 8 +++- fasthtml/xtend.py | 23 +++++----- nbs/api/00_core.ipynb | 45 ++++++++++++------- nbs/api/01_components.ipynb | 87 +++++++++++++++++++++++-------------- nbs/api/02_xtend.ipynb | 30 ++++++------- nbs/api/04_pico.ipynb | 21 ++++++--- 10 files changed, 177 insertions(+), 105 deletions(-) diff --git a/fasthtml/_modidx.py b/fasthtml/_modidx.py index 1365aa15..0915683f 100644 --- a/fasthtml/_modidx.py +++ b/fasthtml/_modidx.py @@ -248,6 +248,7 @@ 'fasthtml.pico.Grid': ('api/pico.html#grid', 'fasthtml/pico.py'), 'fasthtml.pico.Group': ('api/pico.html#group', 'fasthtml/pico.py'), 'fasthtml.pico.PicoBusy': ('api/pico.html#picobusy', 'fasthtml/pico.py'), + 'fasthtml.pico.PicoBusy4': ('api/pico.html#picobusy4', 'fasthtml/pico.py'), 'fasthtml.pico.Search': ('api/pico.html#search', 'fasthtml/pico.py'), 'fasthtml.pico.set_pico_cls': ('api/pico.html#set_pico_cls', 'fasthtml/pico.py')}, 'fasthtml.starlette': {}, diff --git a/fasthtml/components.py b/fasthtml/components.py index d551da99..eb955bc5 100644 --- a/fasthtml/components.py +++ b/fasthtml/components.py @@ -4,17 +4,17 @@ # %% auto #0 __all__ = ['named', 'html_attrs', 'hx_attrs', 'hx_evts', 'js_evts', 'hx_attrs_annotations', 'hx_evt_attrs', 'js_evt_attrs', - 'evt_attrs', 'attrmap_x', 'ft_html', 'ft_hx', 'File', 'show', 'fill_form', 'fill_dataclass', 'find_inputs', - 'html2ft', 'sse_message', 'A', 'Abbr', 'Address', 'Area', 'Article', 'Aside', 'Audio', 'B', 'Base', 'Bdi', - 'Bdo', 'Blockquote', 'Body', 'Br', 'Button', 'Canvas', 'Caption', 'Cite', 'Code', 'Col', 'Colgroup', 'Data', - 'Datalist', 'Dd', 'Del', 'Details', 'Dfn', 'Dialog', 'Div', 'Dl', 'Dt', 'Em', 'Embed', 'Fencedframe', - 'Fieldset', 'Figcaption', 'Figure', 'Footer', 'Form', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'Head', 'Header', - 'Hgroup', 'Hr', 'I', 'Iframe', 'Img', 'Input', 'Ins', 'Kbd', 'Label', 'Legend', 'Li', 'Link', 'Main', 'Map', - 'Mark', 'Menu', 'Meta', 'Meter', 'Nav', 'Noscript', 'Object', 'Ol', 'Optgroup', 'Option', 'Output', 'P', - 'Picture', 'PortalExperimental', 'Pre', 'Progress', 'Q', 'Rp', 'Rt', 'Ruby', 'S', 'Samp', 'Script', 'Search', - 'Section', 'Select', 'Slot', 'Small', 'Source', 'Span', 'Strong', 'Style', 'Sub', 'Summary', 'Sup', 'Table', - 'Tbody', 'Td', 'Template', 'Textarea', 'Tfoot', 'Th', 'Thead', 'Time', 'Title', 'Tr', 'Track', 'U', 'Ul', - 'Var', 'Video', 'Wbr'] + 'evt_attrs', 'HxPartial', 'attrmap_x', 'ft_html', 'ft_hx', 'File', 'show', 'fill_form', 'fill_dataclass', + 'find_inputs', 'html2ft', 'sse_message', 'A', 'Abbr', 'Address', 'Area', 'Article', 'Aside', 'Audio', 'B', + 'Base', 'Bdi', 'Bdo', 'Blockquote', 'Body', 'Br', 'Button', 'Canvas', 'Caption', 'Cite', 'Code', 'Col', + 'Colgroup', 'Data', 'Datalist', 'Dd', 'Del', 'Details', 'Dfn', 'Dialog', 'Div', 'Dl', 'Dt', 'Em', 'Embed', + 'Fencedframe', 'Fieldset', 'Figcaption', 'Figure', 'Footer', 'Form', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', + 'Head', 'Header', 'Hgroup', 'Hr', 'I', 'Iframe', 'Img', 'Input', 'Ins', 'Kbd', 'Label', 'Legend', 'Li', + 'Link', 'Main', 'Map', 'Mark', 'Menu', 'Meta', 'Meter', 'Nav', 'Noscript', 'Object', 'Ol', 'Optgroup', + 'Option', 'Output', 'P', 'Picture', 'PortalExperimental', 'Pre', 'Progress', 'Q', 'Rp', 'Rt', 'Ruby', 'S', + 'Samp', 'Script', 'Search', 'Section', 'Select', 'Slot', 'Small', 'Source', 'Span', 'Strong', 'Style', 'Sub', + 'Summary', 'Sup', 'Table', 'Tbody', 'Td', 'Template', 'Textarea', 'Tfoot', 'Th', 'Thead', 'Time', 'Title', + 'Tr', 'Track', 'U', 'Ul', 'Var', 'Video', 'Wbr'] # %% ../nbs/api/01_components.ipynb #8e2d405b from dataclasses import dataclass, asdict, is_dataclass, make_dataclass, replace, astuple, MISSING @@ -119,6 +119,10 @@ def ft_hx(tag: str, *c, target_id=None, hx_vals=None, hx_target=None, **kwargs): 'Td', 'Template', 'Textarea', 'Tfoot', 'Th', 'Thead', 'Time', 'Title', 'Tr', 'Track', 'U', 'Ul', 'Var', 'Video', 'Wbr'] for o in _all_: _g[o] = partial(ft_hx, o.lower()) +# %% ../nbs/api/01_components.ipynb #dfa8f01c +HxPartial = partial(ft_hx, 'hx-partial') # For hx v4, manually mapping because Python doesn't allow hyphens + + # %% ../nbs/api/01_components.ipynb #fab04fb3 def File(fname): "Use the unescaped text in file `fname` directly" @@ -244,7 +248,9 @@ def _parse(elm, lvl=0, indent=4): return _parse(soup, 1) # %% ../nbs/api/01_components.ipynb #c6203402 -def sse_message(elm, event='message'): +def sse_message(elm, event='message', htmx4=False): "Convert element `elm` into a format suitable for SSE streaming" data = '\n'.join(f'data: {o}' for o in to_xml(elm).splitlines()) + # In htmx v4, if only want swap -> omit event. Including it triggers custom DOM event + if htmx4 and event == 'message': return f'{data}\n\n' return f'event: {event}\n{data}\n\n' diff --git a/fasthtml/core.py b/fasthtml/core.py index e669bca0..19d821a4 100644 --- a/fasthtml/core.py +++ b/fasthtml/core.py @@ -60,6 +60,8 @@ def snake2hyphens(s:str): history_restore_request="HX-History-Restore-Request", prompt="HX-Prompt", request="HX-Request", + request_type="HX-Request-Type", # v4: partial or full update + source="HX-Source", # v4: replaces v2 HX-Trigger but with tag#id instead just id target="HX-Target", trigger_name="HX-Trigger-Name", trigger="HX-Trigger") @@ -67,7 +69,8 @@ def snake2hyphens(s:str): @dataclass class HtmxHeaders: boosted:str|None=None; current_url:str|None=None; history_restore_request:str|None=None; prompt:str|None=None - request:str|None=None; target:str|None=None; trigger_name:str|None=None; trigger:str|None=None + request:str|None=None; request_type:str|None=None; source:str|None=None; target:str|None=None; + trigger_name:str|None=None; trigger:str|None=None def __bool__(self): return any(hasattr(self,o) for o in htmx_hdrs) def _get_htmx(h): @@ -227,6 +230,7 @@ async def _find_p(conn, data, hdrs, arg:str, p:Parameter): if res in (empty,None): res = conn.query_params.getlist(arg) if res==[]: res = None if res in (empty,None): res = data.get(arg, None) + if res in (empty, None) and conn.scope['app'].htmx4: res = data.get('values', {}).get(arg, None) if res in (empty,None): if p.default is empty: if isinstance(conn, Request): raise HTTPException(400, f"Missing required field: {arg}") @@ -267,7 +271,7 @@ async def _handle(f, *args, **kwargs): # %% ../nbs/api/00_core.ipynb #ad0f0e87 async def _wrap_ws(ws, data, params): - hdrs = Headers({k.lower():v for k,v in data.pop('HEADERS', {}).items()}) + hdrs = Headers({k.lower():v for k,v in (data.pop('HEADERS', {}) or data.pop('headers', {})).items()}) return await _find_ps(ws, data, hdrs, params) # %% ../nbs/api/00_core.ipynb #dcc15129 @@ -496,7 +500,9 @@ async def _wrap_call(f, req, params): "remove-me": "https://cdn.jsdelivr.net/npm/htmx-ext-remove-me@2.0.0/remove-me.js", "debug": "https://unpkg.com/htmx.org@1.9.12/dist/ext/debug.js", "ws": "https://cdn.jsdelivr.net/npm/htmx-ext-ws@2.0.3/ws.js", - "chunked-transfer": "https://cdn.jsdelivr.net/npm/htmx-ext-transfer-encoding-chunked@0.4.0/transfer-encoding-chunked.js" + "ws4": "https://unpkg.com/htmx.org@4.0.0-alpha8/dist/ext/hx-ws.js", + "chunked-transfer": "https://cdn.jsdelivr.net/npm/htmx-ext-transfer-encoding-chunked@0.4.0/transfer-encoding-chunked.js", + "sse4": "https://unpkg.com/htmx.org@4.0.0-alpha8/dist/ext/hx-sse.js" } # %% ../nbs/api/00_core.ipynb #60cb52ea @@ -545,11 +551,17 @@ def _sub(m): return p + ('?' + urlencode({k:'' if v in (False,None) else v for k,v in kw.items()},doseq=True) if kw else '') # %% ../nbs/api/00_core.ipynb #f86690c4 -def def_hdrs(htmx=True, surreal=True): +def def_hdrs(htmx=True, htmx4=False, surreal=True): "Default headers for a FastHTML app" hdrs = [] if surreal: hdrs = [surrsrc,scopesrc] + hdrs + if htmx and htmx4: raise ValueError("Cannot enable both htmx and htmx4") if htmx: hdrs = [htmxsrc,fhjsscr] + hdrs + if htmx4: + # metaCharacter="-" makes htmx4 use dashes instead of colons + meta_cfg = Meta(name="htmx-config", content=json.dumps({"metaCharacter": "-"})) + hdrs = [meta_cfg, htmx4src,fhjsscr] + hdrs + # TODO: Check if fhjsscr works with htmx4 return [charset, viewport] + hdrs # %% ../nbs/api/00_core.ipynb #2c5285ae @@ -597,19 +609,22 @@ def on_event(self, event_type): class FastHTML(Starlette): def __init__(self, debug=False, routes=None, middleware=None, title: str = "FastHTML page", exception_handlers=None, on_startup=None, on_shutdown=None, lifespan=None, hdrs=None, ftrs=None, exts=None, - before=None, after=None, surreal=True, htmx=True, default_hdrs=True, sess_cls=SessionMiddleware, + before=None, after=None, surreal=True, htmx=True, htmx4=True, default_hdrs=True, sess_cls=SessionMiddleware, secret_key=None, session_cookie='session_', max_age=365*24*3600, sess_path='/', same_site='lax', sess_https_only=False, sess_domain=None, key_fname='.sesskey', body_wrap=noop_body, htmlkw=None, nb_hdrs=False, canonical=True, **bodykw): middleware,before,after = map(_list, (middleware,before,after)) self.title,self.canonical,self.session_cookie,self.key_fname = title,canonical,session_cookie,key_fname + self.htmx4 = htmx4 hdrs,ftrs,exts = map(listify, (hdrs,ftrs,exts)) + if htmx4 and exts: + exts = ['ws4' if e in ('ws', 'ws4') else 'sse4' if e in ('sse', 'sse4') else e for e in exts] exts = {k:htmx_exts[k] for k in exts} htmlkw = htmlkw or {} - if default_hdrs: hdrs = def_hdrs(htmx, surreal=surreal) + hdrs + if default_hdrs: hdrs = def_hdrs(htmx, htmx4, surreal=surreal) + hdrs hdrs += [Script(src=ext) for ext in exts.values()] if IN_NOTEBOOK: - hdrs.append(iframe_scr) + hdrs.append(iframe_scr) # TODO: check iframe_scr work with htmx4 from IPython.display import display,HTML if nb_hdrs: display(HTML(to_xml(tuple(hdrs)))) middleware.append(cors_allow) @@ -619,8 +634,8 @@ def __init__(self, debug=False, routes=None, middleware=None, title: str = "Fast self.secret_key = get_key(secret_key, key_fname) if sess_cls: sess = Middleware(sess_cls, secret_key=self.secret_key,session_cookie=session_cookie, - max_age=max_age, path=sess_path, same_site=same_site, - https_only=sess_https_only, domain=sess_domain) + max_age=max_age, path=sess_path, same_site=same_site, + https_only=sess_https_only, domain=sess_domain) middleware.append(sess) exception_handlers = ifnone(exception_handlers, {}) if 404 not in exception_handlers: diff --git a/fasthtml/fastapp.py b/fasthtml/fastapp.py index 32e74595..8ff65895 100644 --- a/fasthtml/fastapp.py +++ b/fasthtml/fastapp.py @@ -46,6 +46,7 @@ def fast_app( pico:Optional[bool]=None, # Include PicoCSS header? surreal:Optional[bool]=True, # Include surreal.js/scope headers? htmx:Optional[bool]=True, # Include HTMX header? + htmx4:Optional[bool]=False, exts:Optional[list|str]=None, # HTMX extension names to include canonical:bool=True, # Automatically include canonical link? secret_key:Optional[str]=None, # Signing key for sessions @@ -71,7 +72,7 @@ def fast_app( app = _app_factory(hdrs=h, ftrs=ftrs, before=before, middleware=middleware, live=live, debug=debug, title=title, routes=routes, exception_handlers=exception_handlers, on_startup=on_startup, on_shutdown=on_shutdown, lifespan=lifespan, default_hdrs=default_hdrs, secret_key=secret_key, canonical=canonical, session_cookie=session_cookie, max_age=max_age, sess_path=sess_path, same_site=same_site, sess_https_only=sess_https_only, - sess_domain=sess_domain, key_fname=key_fname, exts=exts, surreal=surreal, htmx=htmx, htmlkw=htmlkw, + sess_domain=sess_domain, key_fname=key_fname, exts=exts, surreal=surreal, htmx=htmx, htmx4=htmx4, htmlkw=htmlkw, reload_attempts=reload_attempts, reload_interval=reload_interval, body_wrap=body_wrap, nb_hdrs=nb_hdrs, **(bodykw or {})) app.static_route_exts(static_path=static_path) if not db_file: return app,app.route @@ -86,4 +87,3 @@ def fast_app( dbtbls = [_get_tbl(db.t, k, v) for k,v in tbls.items()] if len(dbtbls)==1: dbtbls=dbtbls[0] return app,app.route,*dbtbls - diff --git a/fasthtml/pico.py b/fasthtml/pico.py index bda6d1c0..4a96177d 100644 --- a/fasthtml/pico.py +++ b/fasthtml/pico.py @@ -4,7 +4,7 @@ # %% auto #0 __all__ = ['picocss', 'picolink', 'picocondcss', 'picocondlink', 'set_pico_cls', 'Card', 'Group', 'Search', 'Grid', 'DialogX', - 'Container', 'PicoBusy'] + 'Container', 'PicoBusy', 'PicoBusy4'] # %% ../nbs/api/04_pico.ipynb #8e2d405b from typing import Any @@ -88,3 +88,9 @@ def Container(*args, **kwargs)->FT: def PicoBusy(): return (HtmxOn('beforeRequest', "event.detail.elt.setAttribute('aria-busy', 'true' )"), HtmxOn('afterRequest', "event.detail.elt.setAttribute('aria-busy', 'false')")) + +# %% ../nbs/api/04_pico.ipynb #8f83e9ea +def PicoBusy4(): + "PicoBusy for hx 4, with new event names and object structure" + return (HtmxOn('before:request', "event.detail.ctx.sourceElement.setAttribute('aria-busy', 'true' )"), + HtmxOn('after:request', "event.detail.ctx.sourceElement.setAttribute('aria-busy', 'false')")) diff --git a/fasthtml/xtend.py b/fasthtml/xtend.py index dac30b32..3f5fbe09 100644 --- a/fasthtml/xtend.py +++ b/fasthtml/xtend.py @@ -243,23 +243,22 @@ def Favicon(light_icon, dark_icon): # %% ../nbs/api/02_xtend.ipynb #50444181 def clear(id): return Div(hx_swap_oob='innerHTML', id=id) -# %% ../nbs/api/02_xtend.ipynb #09b2e3a5 -sid_scr = Script(''' +# %% ../nbs/api/02_xtend.ipynb #f46160fb +sid_scr = Script(""" function uuid() { return [...crypto.getRandomValues(new Uint8Array(10))].map(b=>b.toString(36)).join(''); } - sessionStorage.setItem("sid", sessionStorage.getItem("sid") || uuid()); -htmx.on("htmx:configRequest", (e) => { - const sid = sessionStorage.getItem("sid"); - if (sid) { - const url = new URL(e.detail.path, window.location.origin); - url.searchParams.set('sid', sid); - e.detail.path = url.pathname + url.search; - } -}); -''') +function addSid(url, sid) { + const u = new URL(url, window.location.origin); + u.searchParams.set('sid', sid); + return u.pathname + u.search; +} + +htmx.on("htmx:configRequest", (e) => { const sid = sessionStorage.getItem("sid"); if (sid) e.detail.path = addSid(e.detail.path, sid); }); // htmx v2 +htmx.on("htmx:config:request", (e) => { const sid = sessionStorage.getItem("sid"); if (sid) e.detail.ctx.request.action = addSid(e.detail.ctx.request.action, sid); }); // htmx v4 +""") # %% ../nbs/api/02_xtend.ipynb #579e1f33 def with_sid(app, dest, path='/'): diff --git a/nbs/api/00_core.ipynb b/nbs/api/00_core.ipynb index cc9c556b..eeea5b4b 100644 --- a/nbs/api/00_core.ipynb +++ b/nbs/api/00_core.ipynb @@ -132,10 +132,10 @@ { "data": { "text/plain": [ - "datetime.datetime(2026, 3, 27, 14, 0)" + "datetime.datetime(2026, 3, 30, 14, 0)" ] }, - "execution_count": 13, + "execution_count": 36, "metadata": {}, "output_type": "execute_result" } @@ -156,7 +156,7 @@ "True" ] }, - "execution_count": 14, + "execution_count": 37, "metadata": {}, "output_type": "execute_result" } @@ -191,7 +191,7 @@ "'Snake-Case'" ] }, - "execution_count": 16, + "execution_count": 39, "metadata": {}, "output_type": "execute_result" } @@ -214,6 +214,8 @@ " history_restore_request=\"HX-History-Restore-Request\",\n", " prompt=\"HX-Prompt\",\n", " request=\"HX-Request\",\n", + " request_type=\"HX-Request-Type\", # v4: partial or full update\n", + " source=\"HX-Source\", # v4: replaces v2 HX-Trigger but with tag#id instead just id\n", " target=\"HX-Target\",\n", " trigger_name=\"HX-Trigger-Name\",\n", " trigger=\"HX-Trigger\")\n", @@ -221,7 +223,8 @@ "@dataclass\n", "class HtmxHeaders:\n", " boosted:str|None=None; current_url:str|None=None; history_restore_request:str|None=None; prompt:str|None=None\n", - " request:str|None=None; target:str|None=None; trigger_name:str|None=None; trigger:str|None=None\n", + " request:str|None=None; request_type:str|None=None; source:str|None=None; target:str|None=None; \n", + " trigger_name:str|None=None; trigger:str|None=None\n", " def __bool__(self): return any(hasattr(self,o) for o in htmx_hdrs)\n", "\n", "def _get_htmx(h):\n", @@ -258,10 +261,10 @@ { "data": { "text/plain": [ - "HtmxHeaders(boosted=None, current_url=None, history_restore_request=None, prompt=None, request='1', target=None, trigger_name=None, trigger=None)" + "HtmxHeaders(boosted=None, current_url=None, history_restore_request=None, prompt=None, request='1', request_type=None, source=None, target=None, trigger_name=None, trigger=None)" ] }, - "execution_count": 19, + "execution_count": 42, "metadata": {}, "output_type": "execute_result" } @@ -819,6 +822,7 @@ " if res in (empty,None): res = conn.query_params.getlist(arg)\n", " if res==[]: res = None\n", " if res in (empty,None): res = data.get(arg, None)\n", + " if res in (empty, None) and conn.scope['app'].htmx4: res = data.get('values', {}).get(arg, None) \n", " if res in (empty,None):\n", " if p.default is empty:\n", " if isinstance(conn, Request): raise HTTPException(400, f\"Missing required field: {arg}\")\n", @@ -1083,7 +1087,7 @@ "source": [ "#| export\n", "async def _wrap_ws(ws, data, params):\n", - " hdrs = Headers({k.lower():v for k,v in data.pop('HEADERS', {}).items()})\n", + " hdrs = Headers({k.lower():v for k,v in (data.pop('HEADERS', {}) or data.pop('headers', {})).items()})\n", " return await _find_ps(ws, data, hdrs, params)" ] }, @@ -1528,7 +1532,9 @@ " \"remove-me\": \"https://cdn.jsdelivr.net/npm/htmx-ext-remove-me@2.0.0/remove-me.js\",\n", " \"debug\": \"https://unpkg.com/htmx.org@1.9.12/dist/ext/debug.js\",\n", " \"ws\": \"https://cdn.jsdelivr.net/npm/htmx-ext-ws@2.0.3/ws.js\",\n", - " \"chunked-transfer\": \"https://cdn.jsdelivr.net/npm/htmx-ext-transfer-encoding-chunked@0.4.0/transfer-encoding-chunked.js\"\n", + " \"ws4\": \"https://unpkg.com/htmx.org@4.0.0-alpha8/dist/ext/hx-ws.js\",\n", + " \"chunked-transfer\": \"https://cdn.jsdelivr.net/npm/htmx-ext-transfer-encoding-chunked@0.4.0/transfer-encoding-chunked.js\",\n", + " \"sse4\": \"https://unpkg.com/htmx.org@4.0.0-alpha8/dist/ext/hx-sse.js\"\n", "}" ] }, @@ -1700,11 +1706,17 @@ "outputs": [], "source": [ "#| export\n", - "def def_hdrs(htmx=True, surreal=True):\n", + "def def_hdrs(htmx=True, htmx4=False, surreal=True):\n", " \"Default headers for a FastHTML app\"\n", " hdrs = []\n", " if surreal: hdrs = [surrsrc,scopesrc] + hdrs\n", + " if htmx and htmx4: raise ValueError(\"Cannot enable both htmx and htmx4\")\n", " if htmx: hdrs = [htmxsrc,fhjsscr] + hdrs\n", + " if htmx4: \n", + " # metaCharacter=\"-\" makes htmx4 use dashes instead of colons\n", + " meta_cfg = Meta(name=\"htmx-config\", content=json.dumps({\"metaCharacter\": \"-\"}))\n", + " hdrs = [meta_cfg, htmx4src,fhjsscr] + hdrs \n", + " # TODO: Check if fhjsscr works with htmx4\n", " return [charset, viewport] + hdrs" ] }, @@ -1776,19 +1788,22 @@ "class FastHTML(Starlette):\n", " def __init__(self, debug=False, routes=None, middleware=None, title: str = \"FastHTML page\", exception_handlers=None,\n", " on_startup=None, on_shutdown=None, lifespan=None, hdrs=None, ftrs=None, exts=None,\n", - " before=None, after=None, surreal=True, htmx=True, default_hdrs=True, sess_cls=SessionMiddleware,\n", + " before=None, after=None, surreal=True, htmx=True, htmx4=True, default_hdrs=True, sess_cls=SessionMiddleware,\n", " secret_key=None, session_cookie='session_', max_age=365*24*3600, sess_path='/',\n", " same_site='lax', sess_https_only=False, sess_domain=None, key_fname='.sesskey',\n", " body_wrap=noop_body, htmlkw=None, nb_hdrs=False, canonical=True, **bodykw):\n", " middleware,before,after = map(_list, (middleware,before,after))\n", " self.title,self.canonical,self.session_cookie,self.key_fname = title,canonical,session_cookie,key_fname\n", + " self.htmx4 = htmx4\n", " hdrs,ftrs,exts = map(listify, (hdrs,ftrs,exts))\n", + " if htmx4 and exts:\n", + " exts = ['ws4' if e in ('ws', 'ws4') else 'sse4' if e in ('sse', 'sse4') else e for e in exts]\n", " exts = {k:htmx_exts[k] for k in exts}\n", " htmlkw = htmlkw or {}\n", - " if default_hdrs: hdrs = def_hdrs(htmx, surreal=surreal) + hdrs\n", + " if default_hdrs: hdrs = def_hdrs(htmx, htmx4, surreal=surreal) + hdrs\n", " hdrs += [Script(src=ext) for ext in exts.values()]\n", " if IN_NOTEBOOK:\n", - " hdrs.append(iframe_scr)\n", + " hdrs.append(iframe_scr) # TODO: check iframe_scr work with htmx4\n", " from IPython.display import display,HTML\n", " if nb_hdrs: display(HTML(to_xml(tuple(hdrs))))\n", " middleware.append(cors_allow)\n", @@ -1798,8 +1813,8 @@ " self.secret_key = get_key(secret_key, key_fname)\n", " if sess_cls:\n", " sess = Middleware(sess_cls, secret_key=self.secret_key,session_cookie=session_cookie,\n", - " max_age=max_age, path=sess_path, same_site=same_site,\n", - " https_only=sess_https_only, domain=sess_domain)\n", + " max_age=max_age, path=sess_path, same_site=same_site,\n", + " https_only=sess_https_only, domain=sess_domain)\n", " middleware.append(sess)\n", " exception_handlers = ifnone(exception_handlers, {})\n", " if 404 not in exception_handlers:\n", diff --git a/nbs/api/01_components.ipynb b/nbs/api/01_components.ipynb index 244247b4..a2a9c589 100644 --- a/nbs/api/01_components.ipynb +++ b/nbs/api/01_components.ipynb @@ -79,6 +79,9 @@ "outputs": [ { "data": { + "text/html": [ + "

FastHTML is Fast

" + ], "text/markdown": [ "```html\n", "

\n", @@ -87,10 +90,10 @@ "```" ], "text/plain": [ - "p((strong(('FastHTML is ', i(('Fast',),{})),{}),),{'id': 'sentence_id'})" + "

FastHTML is Fast

" ] }, - "execution_count": null, + "execution_count": 10, "metadata": {}, "output_type": "execute_result" } @@ -118,7 +121,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "p((strong(('FastHTML is ', i(('Fast',),{})),{}),),{'id': 'sentence_id'})\n" + "

FastHTML is Fast

\n" ] } ], @@ -158,7 +161,7 @@ "'hx_target=#sentence_id'" ] }, - "execution_count": null, + "execution_count": 13, "metadata": {}, "output_type": "execute_result" } @@ -191,7 +194,7 @@ "'hx_target=#sentence_id'" ] }, - "execution_count": null, + "execution_count": 15, "metadata": {}, "output_type": "execute_result" } @@ -224,7 +227,7 @@ "'sentence_id...'" ] }, - "execution_count": null, + "execution_count": 17, "metadata": {}, "output_type": "execute_result" } @@ -334,16 +337,19 @@ "outputs": [ { "data": { + "text/html": [ + "" + ], "text/markdown": [ "```html\n", "\n", "```" ], "text/plain": [ - "a((),{'@click.away': 1})" + "" ] }, - "execution_count": null, + "execution_count": 22, "metadata": {}, "output_type": "execute_result" } @@ -360,16 +366,19 @@ "outputs": [ { "data": { + "text/html": [ + "" + ], "text/markdown": [ "```html\n", "\n", "```" ], "text/plain": [ - "a((),{'@click.away': 1})" + "" ] }, - "execution_count": null, + "execution_count": 23, "metadata": {}, "output_type": "execute_result" } @@ -386,16 +395,19 @@ "outputs": [ { "data": { + "text/html": [ + "" + ], "text/markdown": [ "```html\n", "\n", "```" ], "text/plain": [ - "a((),{'@click.away': 1})" + "" ] }, - "execution_count": null, + "execution_count": 24, "metadata": {}, "output_type": "execute_result" } @@ -422,16 +434,19 @@ "outputs": [ { "data": { + "text/html": [ + "" + ], "text/markdown": [ "```html\n", "\n", "```" ], "text/plain": [ - "a((),{'id': 'someid', 'name': 'someid'})" + "" ] }, - "execution_count": null, + "execution_count": 26, "metadata": {}, "output_type": "execute_result" } @@ -464,16 +479,19 @@ "outputs": [ { "data": { + "text/html": [ + "" + ], "text/markdown": [ "```html\n", "\n", "```" ], "text/plain": [ - "a((),{'hx-vals': '{\"a\": 1}'})" + "" ] }, - "execution_count": null, + "execution_count": 28, "metadata": {}, "output_type": "execute_result" } @@ -490,16 +508,19 @@ "outputs": [ { "data": { + "text/html": [ + "" + ], "text/markdown": [ "```html\n", "\n", "```" ], "text/plain": [ - "a((),{'hx-target': '#someid'})" + "" ] }, - "execution_count": null, + "execution_count": 29, "metadata": {}, "output_type": "execute_result" } @@ -530,6 +551,17 @@ "for o in _all_: _g[o] = partial(ft_hx, o.lower())" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "dfa8f01c", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "HxPartial = partial(ft_hx, 'hx-partial') # For hx v4, manually mapping because Python doesn't allow hyphens\n" + ] + }, { "cell_type": "markdown", "id": "1b030be9", @@ -1251,9 +1283,11 @@ "outputs": [], "source": [ "#| export\n", - "def sse_message(elm, event='message'):\n", + "def sse_message(elm, event='message', htmx4=False):\n", " \"Convert element `elm` into a format suitable for SSE streaming\"\n", " data = '\\n'.join(f'data: {o}' for o in to_xml(elm).splitlines())\n", + " # In htmx v4, if only want swap -> omit event. Including it triggers custom DOM event\n", + " if htmx4 and event == 'message': return f'{data}\\n\\n'\n", " return f'event: {event}\\n{data}\\n\\n'" ] }, @@ -1354,22 +1388,11 @@ "#| hide\n", "import nbdev; nbdev.nbdev_export()" ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "74aeda124534ac06", - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { - "kernelspec": { - "display_name": "python3", - "language": "python", - "name": "python3" - } + "solveit_dialog_mode": "learning", + "solveit_ver": 2 }, "nbformat": 4, "nbformat_minor": 5 diff --git a/nbs/api/02_xtend.ipynb b/nbs/api/02_xtend.ipynb index 892f905a..c34673da 100644 --- a/nbs/api/02_xtend.ipynb +++ b/nbs/api/02_xtend.ipynb @@ -654,27 +654,26 @@ { "cell_type": "code", "execution_count": null, - "id": "09b2e3a5", + "id": "f46160fb", "metadata": {}, "outputs": [], "source": [ "#| export\n", - "sid_scr = Script('''\n", + "sid_scr = Script(\"\"\"\n", "function uuid() {\n", " return [...crypto.getRandomValues(new Uint8Array(10))].map(b=>b.toString(36)).join('');\n", "}\n", - "\n", "sessionStorage.setItem(\"sid\", sessionStorage.getItem(\"sid\") || uuid());\n", "\n", - "htmx.on(\"htmx:configRequest\", (e) => {\n", - " const sid = sessionStorage.getItem(\"sid\");\n", - " if (sid) {\n", - " const url = new URL(e.detail.path, window.location.origin);\n", - " url.searchParams.set('sid', sid);\n", - " e.detail.path = url.pathname + url.search;\n", - " }\n", - "});\n", - "''')" + "function addSid(url, sid) {\n", + " const u = new URL(url, window.location.origin);\n", + " u.searchParams.set('sid', sid);\n", + " return u.pathname + u.search;\n", + "}\n", + "\n", + "htmx.on(\"htmx:configRequest\", (e) => { const sid = sessionStorage.getItem(\"sid\"); if (sid) e.detail.path = addSid(e.detail.path, sid); }); // htmx v2\n", + "htmx.on(\"htmx:config:request\", (e) => { const sid = sessionStorage.getItem(\"sid\"); if (sid) e.detail.ctx.request.action = addSid(e.detail.ctx.request.action, sid); }); // htmx v4\n", + "\"\"\")" ] }, { @@ -1046,11 +1045,8 @@ } ], "metadata": { - "kernelspec": { - "display_name": "python3", - "language": "python", - "name": "python3" - } + "solveit_dialog_mode": "learning", + "solveit_ver": 2 }, "nbformat": 4, "nbformat_minor": 5 diff --git a/nbs/api/04_pico.ipynb b/nbs/api/04_pico.ipynb index ab9ff878..6c4119d9 100644 --- a/nbs/api/04_pico.ipynb +++ b/nbs/api/04_pico.ipynb @@ -389,6 +389,20 @@ " HtmxOn('afterRequest', \"event.detail.elt.setAttribute('aria-busy', 'false')\"))" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "8f83e9ea", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "def PicoBusy4():\n", + " \"PicoBusy for hx 4, with new event names and object structure\"\n", + " return (HtmxOn('before:request', \"event.detail.ctx.sourceElement.setAttribute('aria-busy', 'true' )\"),\n", + " HtmxOn('after:request', \"event.detail.ctx.sourceElement.setAttribute('aria-busy', 'false')\"))" + ] + }, { "cell_type": "markdown", "id": "474e14b4", @@ -418,11 +432,8 @@ } ], "metadata": { - "kernelspec": { - "display_name": "python3", - "language": "python", - "name": "python3" - } + "solveit_dialog_mode": "learning", + "solveit_ver": 2 }, "nbformat": 4, "nbformat_minor": 5 From 6c8fe5e4c477c1b8335a3c77d11548a4412867ef Mon Sep 17 00:00:00 2001 From: Dien-Hoa Date: Mon, 30 Mar 2026 21:39:54 -0400 Subject: [PATCH 02/19] add missing htmx4src --- fasthtml/core.py | 15 ++++++++------- nbs/api/00_core.ipynb | 4 ++++ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/fasthtml/core.py b/fasthtml/core.py index 19d821a4..d7069c37 100644 --- a/fasthtml/core.py +++ b/fasthtml/core.py @@ -3,13 +3,13 @@ # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/api/00_core.ipynb. # %% auto #0 -__all__ = ['empty', 'htmx_hdrs', 'fh_cfg', 'htmx_resps', 'htmx_exts', 'htmxsrc', 'fhjsscr', 'surrsrc', 'scopesrc', 'viewport', - 'charset', 'cors_allow', 'iframe_scr', 'all_meths', 'devtools_loc', 'parsed_date', 'snake2hyphens', - 'HtmxHeaders', 'HttpHeader', 'HtmxResponseHeaders', 'form2dict', 'parse_form', 'ApiReturn', 'JSONResponse', - 'flat_xt', 'Beforeware', 'EventStream', 'signal_shutdown', 'uri', 'decode_uri', 'flat_tuple', 'noop_body', - 'respond', 'is_full_page', 'Redirect', 'get_key', 'qp', 'def_hdrs', 'Lifespan', 'FastHTML', 'HostRoute', - 'nested_name', 'serve', 'Client', 'RouteFuncs', 'APIRouter', 'cookie', 'reg_re_param', 'StaticNoCache', - 'MiddlewareBase', 'FtResponse', 'unqid'] +__all__ = ['empty', 'htmx_hdrs', 'fh_cfg', 'htmx_resps', 'htmx_exts', 'htmxsrc', 'htmx4src', 'fhjsscr', 'surrsrc', 'scopesrc', + 'viewport', 'charset', 'cors_allow', 'iframe_scr', 'all_meths', 'devtools_loc', 'parsed_date', + 'snake2hyphens', 'HtmxHeaders', 'HttpHeader', 'HtmxResponseHeaders', 'form2dict', 'parse_form', 'ApiReturn', + 'JSONResponse', 'flat_xt', 'Beforeware', 'EventStream', 'signal_shutdown', 'uri', 'decode_uri', 'flat_tuple', + 'noop_body', 'respond', 'is_full_page', 'Redirect', 'get_key', 'qp', 'def_hdrs', 'Lifespan', 'FastHTML', + 'HostRoute', 'nested_name', 'serve', 'Client', 'RouteFuncs', 'APIRouter', 'cookie', 'reg_re_param', + 'StaticNoCache', 'MiddlewareBase', 'FtResponse', 'unqid'] # %% ../nbs/api/00_core.ipynb #23503b9e import json,uuid,inspect,types,asyncio,inspect,random,contextlib,httpx,itsdangerous,uvicorn @@ -507,6 +507,7 @@ async def _wrap_call(f, req, params): # %% ../nbs/api/00_core.ipynb #60cb52ea htmxsrc = Script(src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.js") +htmx4src = Script(src="https://unpkg.com/htmx.org@4.0.0-alpha8/dist/htmx.js") fhjsscr = Script(src="https://cdn.jsdelivr.net/gh/answerdotai/fasthtml-js@1.0.12/fasthtml.js") surrsrc = Script(src="https://cdn.jsdelivr.net/gh/answerdotai/surreal@main/surreal.js") scopesrc = Script(src="https://cdn.jsdelivr.net/gh/gnat/css-scope-inline@main/script.js") diff --git a/nbs/api/00_core.ipynb b/nbs/api/00_core.ipynb index eeea5b4b..37811406 100644 --- a/nbs/api/00_core.ipynb +++ b/nbs/api/00_core.ipynb @@ -1573,6 +1573,7 @@ "source": [ "#| export\n", "htmxsrc = Script(src=\"https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.js\")\n", + "htmx4src = Script(src=\"https://unpkg.com/htmx.org@4.0.0-alpha8/dist/htmx.js\")\n", "fhjsscr = Script(src=\"https://cdn.jsdelivr.net/gh/answerdotai/fasthtml-js@1.0.12/fasthtml.js\")\n", "surrsrc = Script(src=\"https://cdn.jsdelivr.net/gh/answerdotai/surreal@main/surreal.js\")\n", "scopesrc = Script(src=\"https://cdn.jsdelivr.net/gh/gnat/css-scope-inline@main/script.js\")\n", @@ -4327,6 +4328,9 @@ } ], "metadata": { + "language_info": { + "name": "python" + }, "solveit_dialog_mode": "learning", "solveit_ver": 2 }, From 138b403825a09a45859fd6a310fc40e44f77ad20 Mon Sep 17 00:00:00 2001 From: Dien-Hoa Date: Mon, 30 Mar 2026 23:21:19 -0400 Subject: [PATCH 03/19] HtmxOn and PicoBusy support htmx4 --- fasthtml/_modidx.py | 1 - fasthtml/pico.py | 18 +++++++---------- fasthtml/xtend.py | 6 +++--- nbs/api/02_xtend.ipynb | 9 ++++++--- nbs/api/04_pico.ipynb | 44 ++++++++++-------------------------------- 5 files changed, 26 insertions(+), 52 deletions(-) diff --git a/fasthtml/_modidx.py b/fasthtml/_modidx.py index 0915683f..1365aa15 100644 --- a/fasthtml/_modidx.py +++ b/fasthtml/_modidx.py @@ -248,7 +248,6 @@ 'fasthtml.pico.Grid': ('api/pico.html#grid', 'fasthtml/pico.py'), 'fasthtml.pico.Group': ('api/pico.html#group', 'fasthtml/pico.py'), 'fasthtml.pico.PicoBusy': ('api/pico.html#picobusy', 'fasthtml/pico.py'), - 'fasthtml.pico.PicoBusy4': ('api/pico.html#picobusy4', 'fasthtml/pico.py'), 'fasthtml.pico.Search': ('api/pico.html#search', 'fasthtml/pico.py'), 'fasthtml.pico.set_pico_cls': ('api/pico.html#set_pico_cls', 'fasthtml/pico.py')}, 'fasthtml.starlette': {}, diff --git a/fasthtml/pico.py b/fasthtml/pico.py index 4a96177d..8174e3a3 100644 --- a/fasthtml/pico.py +++ b/fasthtml/pico.py @@ -4,7 +4,7 @@ # %% auto #0 __all__ = ['picocss', 'picolink', 'picocondcss', 'picocondlink', 'set_pico_cls', 'Card', 'Group', 'Search', 'Grid', 'DialogX', - 'Container', 'PicoBusy', 'PicoBusy4'] + 'Container', 'PicoBusy'] # %% ../nbs/api/04_pico.ipynb #8e2d405b from typing import Any @@ -84,13 +84,9 @@ def Container(*args, **kwargs)->FT: "A PicoCSS Container, implemented as a Main with class 'container'" return Main(*args, cls="container", **kwargs) -# %% ../nbs/api/04_pico.ipynb #138dc298 -def PicoBusy(): - return (HtmxOn('beforeRequest', "event.detail.elt.setAttribute('aria-busy', 'true' )"), - HtmxOn('afterRequest', "event.detail.elt.setAttribute('aria-busy', 'false')")) - -# %% ../nbs/api/04_pico.ipynb #8f83e9ea -def PicoBusy4(): - "PicoBusy for hx 4, with new event names and object structure" - return (HtmxOn('before:request', "event.detail.ctx.sourceElement.setAttribute('aria-busy', 'true' )"), - HtmxOn('after:request', "event.detail.ctx.sourceElement.setAttribute('aria-busy', 'false')")) +# %% ../nbs/api/04_pico.ipynb #b8c98614 +def PicoBusy(htmx4=False, metaChar=':'): + evt = (f'before{metaChar}request', f'after{metaChar}request') if htmx4 else ('beforeRequest', 'afterRequest') + elt = 'event.detail.ctx.sourceElement' if htmx4 else 'event.detail.elt' + return (HtmxOn(evt[0], f"{elt}.setAttribute('aria-busy', 'true' )", metaChar=metaChar), + HtmxOn(evt[1], f"{elt}.setAttribute('aria-busy', 'false')", metaChar=metaChar)) diff --git a/fasthtml/xtend.py b/fasthtml/xtend.py index 3f5fbe09..5b15ea9d 100644 --- a/fasthtml/xtend.py +++ b/fasthtml/xtend.py @@ -167,10 +167,10 @@ def run_js(js, id=None, **kw): return Script(js.format(**kw), id=id, hx_swap_oob='true') # %% ../nbs/api/02_xtend.ipynb #365f57a8 -def HtmxOn(eventname:str, code:str): +def HtmxOn(eventname:str, code:str, metaChar=':'): return Script('''domReadyExecute(function() { -document.body.addEventListener("htmx:%s", function(event) { %s }) -})''' % (eventname, code)) +document.body.addEventListener("htmx%s%s", function(event) { %s }) +})''' % (metaChar, eventname, code)) # %% ../nbs/api/02_xtend.ipynb #39f20784 def jsd(org, repo, root, path, prov='gh', typ='script', ver=None, esm=False, **kwargs)->FT: diff --git a/nbs/api/02_xtend.ipynb b/nbs/api/02_xtend.ipynb index c34673da..8d1aa22b 100644 --- a/nbs/api/02_xtend.ipynb +++ b/nbs/api/02_xtend.ipynb @@ -463,10 +463,10 @@ "outputs": [], "source": [ "#| export\n", - "def HtmxOn(eventname:str, code:str):\n", + "def HtmxOn(eventname:str, code:str, metaChar=':'):\n", " return Script('''domReadyExecute(function() {\n", - "document.body.addEventListener(\"htmx:%s\", function(event) { %s })\n", - "})''' % (eventname, code))" + "document.body.addEventListener(\"htmx%s%s\", function(event) { %s })\n", + "})''' % (metaChar, eventname, code))" ] }, { @@ -1045,6 +1045,9 @@ } ], "metadata": { + "language_info": { + "name": "python" + }, "solveit_dialog_mode": "learning", "solveit_ver": 2 }, diff --git a/nbs/api/04_pico.ipynb b/nbs/api/04_pico.ipynb index 6c4119d9..b72e2871 100644 --- a/nbs/api/04_pico.ipynb +++ b/nbs/api/04_pico.ipynb @@ -130,22 +130,7 @@ "outputs": [ { "data": { - "application/javascript": [ - "var sel = '.cell-output, .output_area';\n", - "document.querySelectorAll(sel).forEach(e => e.classList.add('pico'));\n", - "\n", - "new MutationObserver(ms => {\n", - " ms.forEach(m => {\n", - " m.addedNodes.forEach(n => {\n", - " if (n.nodeType === 1) {\n", - " var nc = n.classList;\n", - " if (nc && (nc.contains('cell-output') || nc.contains('output_area'))) nc.add('pico');\n", - " n.querySelectorAll(sel).forEach(e => e.classList.add('pico'));\n", - " }\n", - " });\n", - " });\n", - "}).observe(document.body, { childList: true, subtree: true });" - ], + "application/javascript": "var sel = '.cell-output, .output_area';\ndocument.querySelectorAll(sel).forEach(e => e.classList.add('pico'));\n\nnew MutationObserver(ms => {\n ms.forEach(m => {\n m.addedNodes.forEach(n => {\n if (n.nodeType === 1) {\n var nc = n.classList;\n if (nc && (nc.contains('cell-output') || nc.contains('output_area'))) nc.add('pico');\n n.querySelectorAll(sel).forEach(e => e.classList.add('pico'));\n }\n });\n });\n}).observe(document.body, { childList: true, subtree: true });", "text/plain": [ "" ] @@ -379,28 +364,16 @@ { "cell_type": "code", "execution_count": null, - "id": "138dc298", + "id": "b8c98614", "metadata": {}, "outputs": [], "source": [ "#| export\n", - "def PicoBusy():\n", - " return (HtmxOn('beforeRequest', \"event.detail.elt.setAttribute('aria-busy', 'true' )\"),\n", - " HtmxOn('afterRequest', \"event.detail.elt.setAttribute('aria-busy', 'false')\"))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8f83e9ea", - "metadata": {}, - "outputs": [], - "source": [ - "#| export\n", - "def PicoBusy4():\n", - " \"PicoBusy for hx 4, with new event names and object structure\"\n", - " return (HtmxOn('before:request', \"event.detail.ctx.sourceElement.setAttribute('aria-busy', 'true' )\"),\n", - " HtmxOn('after:request', \"event.detail.ctx.sourceElement.setAttribute('aria-busy', 'false')\"))" + "def PicoBusy(htmx4=False, metaChar=':'):\n", + " evt = (f'before{metaChar}request', f'after{metaChar}request') if htmx4 else ('beforeRequest', 'afterRequest')\n", + " elt = 'event.detail.ctx.sourceElement' if htmx4 else 'event.detail.elt'\n", + " return (HtmxOn(evt[0], f\"{elt}.setAttribute('aria-busy', 'true' )\", metaChar=metaChar),\n", + " HtmxOn(evt[1], f\"{elt}.setAttribute('aria-busy', 'false')\", metaChar=metaChar))" ] }, { @@ -432,6 +405,9 @@ } ], "metadata": { + "language_info": { + "name": "python" + }, "solveit_dialog_mode": "learning", "solveit_ver": 2 }, From 0c0c964453b8d6ad4d7932788432bcb818c1a0c4 Mon Sep 17 00:00:00 2001 From: Dien-Hoa Date: Tue, 31 Mar 2026 21:16:25 -0400 Subject: [PATCH 04/19] fix metaChar in sid_src --- fasthtml/xtend.py | 4 +++- nbs/api/02_xtend.ipynb | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/fasthtml/xtend.py b/fasthtml/xtend.py index 5b15ea9d..339298f8 100644 --- a/fasthtml/xtend.py +++ b/fasthtml/xtend.py @@ -255,9 +255,11 @@ def clear(id): return Div(hx_swap_oob='innerHTML', id=id) u.searchParams.set('sid', sid); return u.pathname + u.search; } + +const mc = htmx.config?.metaCharacter || ':'; htmx.on("htmx:configRequest", (e) => { const sid = sessionStorage.getItem("sid"); if (sid) e.detail.path = addSid(e.detail.path, sid); }); // htmx v2 -htmx.on("htmx:config:request", (e) => { const sid = sessionStorage.getItem("sid"); if (sid) e.detail.ctx.request.action = addSid(e.detail.ctx.request.action, sid); }); // htmx v4 +htmx.on(`htmx${mc}config${mc}request`, (e) => { const sid = sessionStorage.getItem("sid"); if (sid) e.detail.ctx.request.action = addSid(e.detail.ctx.request.action, sid); }); // htmx v4 """) # %% ../nbs/api/02_xtend.ipynb #579e1f33 diff --git a/nbs/api/02_xtend.ipynb b/nbs/api/02_xtend.ipynb index 8d1aa22b..2c69e2fd 100644 --- a/nbs/api/02_xtend.ipynb +++ b/nbs/api/02_xtend.ipynb @@ -670,9 +670,11 @@ " u.searchParams.set('sid', sid);\n", " return u.pathname + u.search;\n", "}\n", + " \n", + "const mc = htmx.config?.metaCharacter || ':';\n", "\n", "htmx.on(\"htmx:configRequest\", (e) => { const sid = sessionStorage.getItem(\"sid\"); if (sid) e.detail.path = addSid(e.detail.path, sid); }); // htmx v2\n", - "htmx.on(\"htmx:config:request\", (e) => { const sid = sessionStorage.getItem(\"sid\"); if (sid) e.detail.ctx.request.action = addSid(e.detail.ctx.request.action, sid); }); // htmx v4\n", + "htmx.on(`htmx${mc}config${mc}request`, (e) => { const sid = sessionStorage.getItem(\"sid\"); if (sid) e.detail.ctx.request.action = addSid(e.detail.ctx.request.action, sid); }); // htmx v4\n", "\"\"\")" ] }, From 32c2421ccc8e8dfe45144ab24558372da1cce302 Mon Sep 17 00:00:00 2001 From: Dien-Hoa Date: Wed, 8 Apr 2026 16:11:31 -0400 Subject: [PATCH 05/19] remove checking both htmx and htmx4 in def_hdrs --- fasthtml/core.py | 8 +++---- nbs/api/00_core.ipynb | 42 ++++++++++++++++--------------------- nbs/api/01_components.ipynb | 25 ++++++++++------------ nbs/api/02_xtend.ipynb | 8 +------ nbs/api/04_pico.ipynb | 8 +------ 5 files changed, 35 insertions(+), 56 deletions(-) diff --git a/fasthtml/core.py b/fasthtml/core.py index d7069c37..d122ef2e 100644 --- a/fasthtml/core.py +++ b/fasthtml/core.py @@ -556,13 +556,13 @@ def def_hdrs(htmx=True, htmx4=False, surreal=True): "Default headers for a FastHTML app" hdrs = [] if surreal: hdrs = [surrsrc,scopesrc] + hdrs - if htmx and htmx4: raise ValueError("Cannot enable both htmx and htmx4") - if htmx: hdrs = [htmxsrc,fhjsscr] + hdrs if htmx4: # metaCharacter="-" makes htmx4 use dashes instead of colons meta_cfg = Meta(name="htmx-config", content=json.dumps({"metaCharacter": "-"})) hdrs = [meta_cfg, htmx4src,fhjsscr] + hdrs - # TODO: Check if fhjsscr works with htmx4 + # TODO: Check if fhjsscr works with htmx4 + elif htmx: + hdrs = [htmxsrc,fhjsscr] + hdrs return [charset, viewport] + hdrs # %% ../nbs/api/00_core.ipynb #2c5285ae @@ -610,7 +610,7 @@ def on_event(self, event_type): class FastHTML(Starlette): def __init__(self, debug=False, routes=None, middleware=None, title: str = "FastHTML page", exception_handlers=None, on_startup=None, on_shutdown=None, lifespan=None, hdrs=None, ftrs=None, exts=None, - before=None, after=None, surreal=True, htmx=True, htmx4=True, default_hdrs=True, sess_cls=SessionMiddleware, + before=None, after=None, surreal=True, htmx=True, htmx4=False, default_hdrs=True, sess_cls=SessionMiddleware, secret_key=None, session_cookie='session_', max_age=365*24*3600, sess_path='/', same_site='lax', sess_https_only=False, sess_domain=None, key_fname='.sesskey', body_wrap=noop_body, htmlkw=None, nb_hdrs=False, canonical=True, **bodykw): diff --git a/nbs/api/00_core.ipynb b/nbs/api/00_core.ipynb index 37811406..ee4b957f 100644 --- a/nbs/api/00_core.ipynb +++ b/nbs/api/00_core.ipynb @@ -135,7 +135,7 @@ "datetime.datetime(2026, 3, 30, 14, 0)" ] }, - "execution_count": 36, + "execution_count": null, "metadata": {}, "output_type": "execute_result" } @@ -156,7 +156,7 @@ "True" ] }, - "execution_count": 37, + "execution_count": null, "metadata": {}, "output_type": "execute_result" } @@ -191,7 +191,7 @@ "'Snake-Case'" ] }, - "execution_count": 39, + "execution_count": null, "metadata": {}, "output_type": "execute_result" } @@ -264,7 +264,7 @@ "HtmxHeaders(boosted=None, current_url=None, history_restore_request=None, prompt=None, request='1', request_type=None, source=None, target=None, trigger_name=None, trigger=None)" ] }, - "execution_count": 42, + "execution_count": null, "metadata": {}, "output_type": "execute_result" } @@ -431,7 +431,7 @@ "'HX-Trigger-After-Settle'" ] }, - "execution_count": 29, + "execution_count": null, "metadata": {}, "output_type": "execute_result" } @@ -467,7 +467,7 @@ "HttpHeader(k='HX-Trigger-After-Settle', v='hi')" ] }, - "execution_count": 31, + "execution_count": null, "metadata": {}, "output_type": "execute_result" } @@ -502,7 +502,7 @@ "{'a': int, 'b': str}" ] }, - "execution_count": 33, + "execution_count": null, "metadata": {}, "output_type": "execute_result" } @@ -525,7 +525,7 @@ "{'x': str, 'y': str}" ] }, - "execution_count": 34, + "execution_count": null, "metadata": {}, "output_type": "execute_result" } @@ -675,7 +675,7 @@ "Foo(d={'a': 1})" ] }, - "execution_count": 43, + "execution_count": null, "metadata": {}, "output_type": "execute_result" } @@ -765,7 +765,7 @@ "\"['1', '2']\"" ] }, - "execution_count": 47, + "execution_count": null, "metadata": {}, "output_type": "execute_result" } @@ -898,7 +898,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "{'req': , 'this': , 'a': '1', 'b': HttpHeader(k='value1', v='value3'), 'state': }\n" + "{'req': , 'this': , 'a': '1', 'b': HttpHeader(k='value1', v='value3'), 'state': }\n" ] } ], @@ -924,7 +924,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "{'req': , 'this': , 'a': '1', 'b': HttpHeader(k='value1', v='value3')}\n" + "{'req': , 'this': , 'a': '1', 'b': HttpHeader(k='value1', v='value3')}\n" ] } ], @@ -966,7 +966,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "{'req': , 'this': , 'a': ''}\n" + "{'req': , 'this': , 'a': ''}\n" ] } ], @@ -1711,13 +1711,13 @@ " \"Default headers for a FastHTML app\"\n", " hdrs = []\n", " if surreal: hdrs = [surrsrc,scopesrc] + hdrs\n", - " if htmx and htmx4: raise ValueError(\"Cannot enable both htmx and htmx4\")\n", - " if htmx: hdrs = [htmxsrc,fhjsscr] + hdrs\n", " if htmx4: \n", " # metaCharacter=\"-\" makes htmx4 use dashes instead of colons\n", " meta_cfg = Meta(name=\"htmx-config\", content=json.dumps({\"metaCharacter\": \"-\"}))\n", " hdrs = [meta_cfg, htmx4src,fhjsscr] + hdrs \n", - " # TODO: Check if fhjsscr works with htmx4\n", + " # TODO: Check if fhjsscr works with htmx4\n", + " elif htmx:\n", + " hdrs = [htmxsrc,fhjsscr] + hdrs\n", " return [charset, viewport] + hdrs" ] }, @@ -1789,7 +1789,7 @@ "class FastHTML(Starlette):\n", " def __init__(self, debug=False, routes=None, middleware=None, title: str = \"FastHTML page\", exception_handlers=None,\n", " on_startup=None, on_shutdown=None, lifespan=None, hdrs=None, ftrs=None, exts=None,\n", - " before=None, after=None, surreal=True, htmx=True, htmx4=True, default_hdrs=True, sess_cls=SessionMiddleware,\n", + " before=None, after=None, surreal=True, htmx=True, htmx4=False, default_hdrs=True, sess_cls=SessionMiddleware,\n", " secret_key=None, session_cookie='session_', max_age=365*24*3600, sess_path='/',\n", " same_site='lax', sess_https_only=False, sess_domain=None, key_fname='.sesskey',\n", " body_wrap=noop_body, htmlkw=None, nb_hdrs=False, canonical=True, **bodykw):\n", @@ -4327,13 +4327,7 @@ ] } ], - "metadata": { - "language_info": { - "name": "python" - }, - "solveit_dialog_mode": "learning", - "solveit_ver": 2 - }, + "metadata": {}, "nbformat": 4, "nbformat_minor": 5 } diff --git a/nbs/api/01_components.ipynb b/nbs/api/01_components.ipynb index a2a9c589..630d147e 100644 --- a/nbs/api/01_components.ipynb +++ b/nbs/api/01_components.ipynb @@ -93,7 +93,7 @@ "

FastHTML is Fast

" ] }, - "execution_count": 10, + "execution_count": null, "metadata": {}, "output_type": "execute_result" } @@ -161,7 +161,7 @@ "'hx_target=#sentence_id'" ] }, - "execution_count": 13, + "execution_count": null, "metadata": {}, "output_type": "execute_result" } @@ -194,7 +194,7 @@ "'hx_target=#sentence_id'" ] }, - "execution_count": 15, + "execution_count": null, "metadata": {}, "output_type": "execute_result" } @@ -227,7 +227,7 @@ "'sentence_id...'" ] }, - "execution_count": 17, + "execution_count": null, "metadata": {}, "output_type": "execute_result" } @@ -349,7 +349,7 @@ "" ] }, - "execution_count": 22, + "execution_count": null, "metadata": {}, "output_type": "execute_result" } @@ -378,7 +378,7 @@ "" ] }, - "execution_count": 23, + "execution_count": null, "metadata": {}, "output_type": "execute_result" } @@ -407,7 +407,7 @@ "" ] }, - "execution_count": 24, + "execution_count": null, "metadata": {}, "output_type": "execute_result" } @@ -446,7 +446,7 @@ "" ] }, - "execution_count": 26, + "execution_count": null, "metadata": {}, "output_type": "execute_result" } @@ -491,7 +491,7 @@ "" ] }, - "execution_count": 28, + "execution_count": null, "metadata": {}, "output_type": "execute_result" } @@ -520,7 +520,7 @@ "" ] }, - "execution_count": 29, + "execution_count": null, "metadata": {}, "output_type": "execute_result" } @@ -1390,10 +1390,7 @@ ] } ], - "metadata": { - "solveit_dialog_mode": "learning", - "solveit_ver": 2 - }, + "metadata": {}, "nbformat": 4, "nbformat_minor": 5 } diff --git a/nbs/api/02_xtend.ipynb b/nbs/api/02_xtend.ipynb index 2c69e2fd..b4397476 100644 --- a/nbs/api/02_xtend.ipynb +++ b/nbs/api/02_xtend.ipynb @@ -1046,13 +1046,7 @@ "source": [] } ], - "metadata": { - "language_info": { - "name": "python" - }, - "solveit_dialog_mode": "learning", - "solveit_ver": 2 - }, + "metadata": {}, "nbformat": 4, "nbformat_minor": 5 } diff --git a/nbs/api/04_pico.ipynb b/nbs/api/04_pico.ipynb index b72e2871..698eb29b 100644 --- a/nbs/api/04_pico.ipynb +++ b/nbs/api/04_pico.ipynb @@ -404,13 +404,7 @@ "source": [] } ], - "metadata": { - "language_info": { - "name": "python" - }, - "solveit_dialog_mode": "learning", - "solveit_ver": 2 - }, + "metadata": {}, "nbformat": 4, "nbformat_minor": 5 } From 014b027a034d6730973d1fbef47146ddded17360 Mon Sep 17 00:00:00 2001 From: Dien-Hoa Date: Wed, 8 Apr 2026 20:30:38 -0400 Subject: [PATCH 06/19] update htmx to beta1 --- fasthtml/core.py | 6 +++--- nbs/api/00_core.ipynb | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/fasthtml/core.py b/fasthtml/core.py index d122ef2e..02bd32c8 100644 --- a/fasthtml/core.py +++ b/fasthtml/core.py @@ -500,14 +500,14 @@ async def _wrap_call(f, req, params): "remove-me": "https://cdn.jsdelivr.net/npm/htmx-ext-remove-me@2.0.0/remove-me.js", "debug": "https://unpkg.com/htmx.org@1.9.12/dist/ext/debug.js", "ws": "https://cdn.jsdelivr.net/npm/htmx-ext-ws@2.0.3/ws.js", - "ws4": "https://unpkg.com/htmx.org@4.0.0-alpha8/dist/ext/hx-ws.js", + "ws4": "https://unpkg.com/htmx.org@4.0.0-beta1/dist/ext/hx-ws.js", "chunked-transfer": "https://cdn.jsdelivr.net/npm/htmx-ext-transfer-encoding-chunked@0.4.0/transfer-encoding-chunked.js", - "sse4": "https://unpkg.com/htmx.org@4.0.0-alpha8/dist/ext/hx-sse.js" + "sse4": "https://unpkg.com/htmx.org@4.0.0-beta1/dist/ext/hx-sse.js" } # %% ../nbs/api/00_core.ipynb #60cb52ea htmxsrc = Script(src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.js") -htmx4src = Script(src="https://unpkg.com/htmx.org@4.0.0-alpha8/dist/htmx.js") +htmx4src = Script(src="https://unpkg.com/htmx.org@4.0.0-beta1/dist/htmx.js") fhjsscr = Script(src="https://cdn.jsdelivr.net/gh/answerdotai/fasthtml-js@1.0.12/fasthtml.js") surrsrc = Script(src="https://cdn.jsdelivr.net/gh/answerdotai/surreal@main/surreal.js") scopesrc = Script(src="https://cdn.jsdelivr.net/gh/gnat/css-scope-inline@main/script.js") diff --git a/nbs/api/00_core.ipynb b/nbs/api/00_core.ipynb index ee4b957f..5e86d8ef 100644 --- a/nbs/api/00_core.ipynb +++ b/nbs/api/00_core.ipynb @@ -1532,9 +1532,9 @@ " \"remove-me\": \"https://cdn.jsdelivr.net/npm/htmx-ext-remove-me@2.0.0/remove-me.js\",\n", " \"debug\": \"https://unpkg.com/htmx.org@1.9.12/dist/ext/debug.js\",\n", " \"ws\": \"https://cdn.jsdelivr.net/npm/htmx-ext-ws@2.0.3/ws.js\",\n", - " \"ws4\": \"https://unpkg.com/htmx.org@4.0.0-alpha8/dist/ext/hx-ws.js\",\n", + " \"ws4\": \"https://unpkg.com/htmx.org@4.0.0-beta1/dist/ext/hx-ws.js\",\n", " \"chunked-transfer\": \"https://cdn.jsdelivr.net/npm/htmx-ext-transfer-encoding-chunked@0.4.0/transfer-encoding-chunked.js\",\n", - " \"sse4\": \"https://unpkg.com/htmx.org@4.0.0-alpha8/dist/ext/hx-sse.js\"\n", + " \"sse4\": \"https://unpkg.com/htmx.org@4.0.0-beta1/dist/ext/hx-sse.js\"\n", "}" ] }, @@ -1573,7 +1573,7 @@ "source": [ "#| export\n", "htmxsrc = Script(src=\"https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.js\")\n", - "htmx4src = Script(src=\"https://unpkg.com/htmx.org@4.0.0-alpha8/dist/htmx.js\")\n", + "htmx4src = Script(src=\"https://unpkg.com/htmx.org@4.0.0-beta1/dist/htmx.js\")\n", "fhjsscr = Script(src=\"https://cdn.jsdelivr.net/gh/answerdotai/fasthtml-js@1.0.12/fasthtml.js\")\n", "surrsrc = Script(src=\"https://cdn.jsdelivr.net/gh/answerdotai/surreal@main/surreal.js\")\n", "scopesrc = Script(src=\"https://cdn.jsdelivr.net/gh/gnat/css-scope-inline@main/script.js\")\n", From 8c649cbfa50a3389cf9735bdf99e821e96b8808d Mon Sep 17 00:00:00 2001 From: Dien-Hoa Date: Thu, 9 Apr 2026 15:58:05 -0400 Subject: [PATCH 07/19] setting metaChar PicoBusy based on htmx4 directly --- fasthtml/pico.py | 3 ++- nbs/api/04_pico.ipynb | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/fasthtml/pico.py b/fasthtml/pico.py index 8174e3a3..aa0c4fe1 100644 --- a/fasthtml/pico.py +++ b/fasthtml/pico.py @@ -85,7 +85,8 @@ def Container(*args, **kwargs)->FT: return Main(*args, cls="container", **kwargs) # %% ../nbs/api/04_pico.ipynb #b8c98614 -def PicoBusy(htmx4=False, metaChar=':'): +def PicoBusy(htmx4=False, metaChar=None): + if metaChar is None: metaChar = '-' if htmx4 else ':' evt = (f'before{metaChar}request', f'after{metaChar}request') if htmx4 else ('beforeRequest', 'afterRequest') elt = 'event.detail.ctx.sourceElement' if htmx4 else 'event.detail.elt' return (HtmxOn(evt[0], f"{elt}.setAttribute('aria-busy', 'true' )", metaChar=metaChar), diff --git a/nbs/api/04_pico.ipynb b/nbs/api/04_pico.ipynb index 698eb29b..f68175df 100644 --- a/nbs/api/04_pico.ipynb +++ b/nbs/api/04_pico.ipynb @@ -369,7 +369,8 @@ "outputs": [], "source": [ "#| export\n", - "def PicoBusy(htmx4=False, metaChar=':'):\n", + "def PicoBusy(htmx4=False, metaChar=None):\n", + " if metaChar is None: metaChar = '-' if htmx4 else ':'\n", " evt = (f'before{metaChar}request', f'after{metaChar}request') if htmx4 else ('beforeRequest', 'afterRequest')\n", " elt = 'event.detail.ctx.sourceElement' if htmx4 else 'event.detail.elt'\n", " return (HtmxOn(evt[0], f\"{elt}.setAttribute('aria-busy', 'true' )\", metaChar=metaChar),\n", From d969b20aa5f6de4ea616f76dd8448cb1b3ee2123 Mon Sep 17 00:00:00 2001 From: Dien-Hoa Date: Thu, 9 Apr 2026 16:11:38 -0400 Subject: [PATCH 08/19] pass htmx4 to HtmxOn --- fasthtml/pico.py | 4 ++-- fasthtml/xtend.py | 3 ++- nbs/api/00_core.ipynb | 6 +----- nbs/api/02_xtend.ipynb | 3 ++- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/fasthtml/pico.py b/fasthtml/pico.py index aa0c4fe1..e4b8d802 100644 --- a/fasthtml/pico.py +++ b/fasthtml/pico.py @@ -89,5 +89,5 @@ def PicoBusy(htmx4=False, metaChar=None): if metaChar is None: metaChar = '-' if htmx4 else ':' evt = (f'before{metaChar}request', f'after{metaChar}request') if htmx4 else ('beforeRequest', 'afterRequest') elt = 'event.detail.ctx.sourceElement' if htmx4 else 'event.detail.elt' - return (HtmxOn(evt[0], f"{elt}.setAttribute('aria-busy', 'true' )", metaChar=metaChar), - HtmxOn(evt[1], f"{elt}.setAttribute('aria-busy', 'false')", metaChar=metaChar)) + return (HtmxOn(evt[0], f"{elt}.setAttribute('aria-busy', 'true' )", htmx4=htmx4), + HtmxOn(evt[1], f"{elt}.setAttribute('aria-busy', 'false')", htmx4=htmx4)) diff --git a/fasthtml/xtend.py b/fasthtml/xtend.py index 339298f8..fb180f15 100644 --- a/fasthtml/xtend.py +++ b/fasthtml/xtend.py @@ -167,7 +167,8 @@ def run_js(js, id=None, **kw): return Script(js.format(**kw), id=id, hx_swap_oob='true') # %% ../nbs/api/02_xtend.ipynb #365f57a8 -def HtmxOn(eventname:str, code:str, metaChar=':'): +def HtmxOn(eventname:str, code:str, htmx4=False, metaChar=None): + if metaChar is None: metaChar = '-' if htmx4 else ':' return Script('''domReadyExecute(function() { document.body.addEventListener("htmx%s%s", function(event) { %s }) })''' % (metaChar, eventname, code)) diff --git a/nbs/api/00_core.ipynb b/nbs/api/00_core.ipynb index 39e22057..4d80299d 100644 --- a/nbs/api/00_core.ipynb +++ b/nbs/api/00_core.ipynb @@ -4523,11 +4523,7 @@ ] } ], - "metadata": { - "language_info": { - "name": "python" - } - }, + "metadata": {}, "nbformat": 4, "nbformat_minor": 5 } diff --git a/nbs/api/02_xtend.ipynb b/nbs/api/02_xtend.ipynb index b4397476..a8a92e08 100644 --- a/nbs/api/02_xtend.ipynb +++ b/nbs/api/02_xtend.ipynb @@ -463,7 +463,8 @@ "outputs": [], "source": [ "#| export\n", - "def HtmxOn(eventname:str, code:str, metaChar=':'):\n", + "def HtmxOn(eventname:str, code:str, htmx4=False, metaChar=None):\n", + " if metaChar is None: metaChar = '-' if htmx4 else ':'\n", " return Script('''domReadyExecute(function() {\n", "document.body.addEventListener(\"htmx%s%s\", function(event) { %s })\n", "})''' % (metaChar, eventname, code))" From 3f816058dd7b31bfff4bce622adac1492d2f2d61 Mon Sep 17 00:00:00 2001 From: Dien-Hoa Date: Sun, 12 Apr 2026 22:17:39 -0400 Subject: [PATCH 09/19] add htmxv4_migration doc --- htmxv4_migration/attribute_inheritance.ipynb | 103 ++++++++ htmxv4_migration/event_structure.ipynb | 109 ++++++++ htmxv4_migration/fasthtml_htmxv4_migration.md | 246 ++++++++++++++++++ htmxv4_migration/partial.ipynb | 135 ++++++++++ htmxv4_migration/request_headers.ipynb | 102 ++++++++ htmxv4_migration/sse.ipynb | 143 ++++++++++ htmxv4_migration/websocket.ipynb | 106 ++++++++ nbs/api/04_pico.ipynb | 4 +- 8 files changed, 946 insertions(+), 2 deletions(-) create mode 100644 htmxv4_migration/attribute_inheritance.ipynb create mode 100644 htmxv4_migration/event_structure.ipynb create mode 100644 htmxv4_migration/fasthtml_htmxv4_migration.md create mode 100644 htmxv4_migration/partial.ipynb create mode 100644 htmxv4_migration/request_headers.ipynb create mode 100644 htmxv4_migration/sse.ipynb create mode 100644 htmxv4_migration/websocket.ipynb diff --git a/htmxv4_migration/attribute_inheritance.ipynb b/htmxv4_migration/attribute_inheritance.ipynb new file mode 100644 index 00000000..dff7d2f0 --- /dev/null +++ b/htmxv4_migration/attribute_inheritance.ipynb @@ -0,0 +1,103 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Attribute Inheritance Demo (htmx v4)\n", + "\n", + "htmx v4 disables implicit inheritance by default.\n", + "To let child elements inherit an attribute from a parent, use the `:inherited` modifier.\n", + "\n", + "With FastHTML's `metaCharacter=\"-\"`, this becomes a Python keyword like `hx_target_inherited`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from fasthtml.common import *\n", + "from fasthtml.jupyter import *\n", + "\n", + "app, rt = fast_app(htmx4=True)\n", + "\n", + "@rt('/')\n", + "def get():\n", + " return Titled(\n", + " 'Attribute Inheritance Demo',\n", + " P('Both buttons inherit `hx-target` from their parent.'),\n", + " Div(\n", + " Button('Load A', hx_get='/a', cls='btn primary'),\n", + " Button('Load B', hx_get='/b', cls='btn secondary'),\n", + " hx_target_inherited='#output', # child buttons inherit #output as their hx_target\n", + " cls='flex gap-2'\n", + " ),\n", + " Div(P('Waiting...'), id='output')\n", + " )\n", + "\n", + "@rt('/a')\n", + "def a():\n", + " return P('Loaded A')\n", + "\n", + "@rt('/b')\n", + "def b():\n", + " return P('Loaded B')\n", + "\n", + "srv = JupyUvi(app)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5246ca66", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "fasthtml (3.12.8)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.8" + }, + "solveit_dialog_mode": "learning", + "solveit_ver": 2 + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/htmxv4_migration/event_structure.ipynb b/htmxv4_migration/event_structure.ipynb new file mode 100644 index 00000000..6c5851bc --- /dev/null +++ b/htmxv4_migration/event_structure.ipynb @@ -0,0 +1,109 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "d10324f5", + "metadata": {}, + "source": [ + "# Click-Load Demo (htmx v4)\n", + "\n", + "Migrated from `click-load.py` (htmx v2).\n", + "\n", + "- `PicoBusy()` → `PicoBusy(htmx4=True)`\n", + " - Event names changed from `beforeRequest` / `afterRequest` to `before-request` / `after-request` in FastHTML with `metaCharacter=\"-\"`\n", + " - Event detail path changed from `event.detail.elt` to `event.detail.ctx.sourceElement`\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "71f58a0c", + "metadata": {}, + "outputs": [], + "source": [ + "from fasthtml.common import *\n", + "from fasthtml.jupyter import *" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "94a9d75b", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from asyncio import sleep\n", + "from random import randint\n", + "import secrets\n", + "\n", + "app,rt = fast_app(htmx4=True)\n", + "\n", + "def mk_row():\n", + " idx = randint(0,1000)\n", + " return Tr( Td(f'Agent {idx}'), Td(f'void{idx}@null.org'), Td(secrets.token_hex(8)))\n", + "\n", + "@rt('/')\n", + "def get():\n", + " return Titled('Click load demo',\n", + " PicoBusy(htmx4=True),\n", + " P(I('Loading is delayed by 1 second to demonstrate use of \"aria-busy\" loading spinner in Pico.')),\n", + " Table(\n", + " Thead( Tr( Th('Name'), Th('Email'), Th('ID'))),\n", + " Tbody( *[mk_row() for _ in range(5)], id='agents')),\n", + " Button('Load Another Agent...',\n", + " hx_get='/more', hx_target='#agents', hx_swap='beforeend', cls='btn primary')\n", + " )\n", + "\n", + "@rt('/more')\n", + "async def get():\n", + " await sleep(1)\n", + " return mk_row()\n", + "\n", + "srv = JupyUvi(app)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "fasthtml (3.12.8)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.8" + }, + "solveit_dialog_mode": "learning", + "solveit_ver": 2 + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/htmxv4_migration/fasthtml_htmxv4_migration.md b/htmxv4_migration/fasthtml_htmxv4_migration.md new file mode 100644 index 00000000..a5b872dc --- /dev/null +++ b/htmxv4_migration/fasthtml_htmxv4_migration.md @@ -0,0 +1,246 @@ +# FastHTML upgrade from htmx2 to htmx4 + +This document is for summarizing what one need to do to upgrade their fasthtml code base from htmxv2 to htmx v4. For the full htmx-level migration details, see: https://four.htmx.org/docs/get-started/migration + +## What's New + +FastHTML now supports htmx v4 via `htmx4=True`. Key changes at the FastHTML level: + +| Area | htmx v2 (default) | htmx v4 (`htmx4=True`) | +|------|-------------------|------------------------| +| **Init** | `fast_app()` or `FastHTML()` | Add `htmx4=True` | +| **Extensions** | `exts='ws'`, `exts='sse'` | Same, auto-maps to v4 versions when htmx4=True | +| **WS attributes** | `ws_connect`, `ws_send`, `hx_ext='ws'` | `hx_ws_connect`, `hx_ws_send` (no `hx_ext`) | +| **SSE** | `sse_connect`, `sse_swap`, `hx_ext='sse'` | `hx_sse_connect` (no `hx_ext`, no `sse_swap`) | +| **SSE message** | `sse_message(data)` | `sse_message(data, htmx4=True)` | +| **Multi-target** | `hx_swap_oob` or named SSE events | `HxPartial(content, hx_target='#id')` | +| **Request headers** | `hx_trigger` (value: `id`) | `hx_source` (value: `tag#id`) | +| **Events in JS** | camelCase (`htmx:beforeRequest`) | colon-separated (`htmx:before:request`) | +| **SVG OOB** | Needs `SvgOob()` wrapper | Plain `Svg()` works directly | +| **Attribute inheritance** | Implicit from parent | Explicit via `:inherited` modifier | + +--- + +## Naming + +### MetaCharacter + +htmx v4 uses `:` as a separator in attribute and event names (e.g. `hx-ws:connect`, `htmx:before:request`). Since colons can't be used in Python keyword arguments, FastHTML sets `metaCharacter="-"` which replaces all `:` with `-`. + +The full conversion chain: + +| Layer | Example | +|-------|---------| +| htmx v4 default | `htmx:before:request` | +| With `metaCharacter="-"` | `htmx-before-request` | +| FastHTML Python (`_` → `-`) | `htmx_before_request` | + +This is configured automatically when you use `htmx4=True`. + +### Renamed Events + +All events follow a new pattern: `htmx:phase:action[:sub-action]` + +Sources: [htmx v4 migration guide: Renamed events](https://four.htmx.org/docs/get-started/migration#renamed-events), [htmx upgrade guide: Step 5 event listeners](https://github.com/bigskysoftware/htmx/blob/four/src/skills/htmx-upgrade-from-htmx2.md#step-5-update-event-listeners) + +| htmx 2.x | htmx 4.x | +|-----------|-----------| +| `htmx:afterOnLoad` | `htmx:after:init` | +| `htmx:afterProcessNode` | `htmx:after:init` | +| `htmx:afterRequest` | `htmx:after:request` | +| `htmx:afterSettle` | `htmx:after:swap` | +| `htmx:afterSwap` | `htmx:after:swap` | +| `htmx:beforeCleanupElement` | `htmx:before:cleanup` | +| `htmx:beforeHistorySave` | `htmx:before:history:update` | +| `htmx:beforeHistoryUpdate` | `htmx:before:history:update` | +| `htmx:beforeOnLoad` | `htmx:before:init` | +| `htmx:beforeProcessNode` | `htmx:before:process` | +| `htmx:beforeRequest` | `htmx:before:request` | +| `htmx:beforeSwap` | `htmx:before:swap` | +| `htmx:beforeTransition` | `htmx:before:viewTransition` | +| `htmx:configRequest` | `htmx:config:request` | +| `htmx:historyCacheMiss` | `htmx:before:history:restore` | +| `htmx:historyRestore` | `htmx:before:history:restore` | +| `htmx:load` | `htmx:after:init` | +| `htmx:oobAfterSwap` | `htmx:after:swap` | +| `htmx:oobBeforeSwap` | `htmx:before:swap` | +| `htmx:pushedIntoHistory` | `htmx:after:history:push` | +| `htmx:replacedInHistory` | `htmx:after:history:replace` | +| `htmx:responseError` | `htmx:error` | +| `htmx:sendAbort` | `htmx:error` | +| `htmx:sendError` | `htmx:error` | +| `htmx:swapError` | `htmx:error` | +| `htmx:targetError` | `htmx:error` | +| `htmx:timeout` | `htmx:error` | + +--- + +## Event Structure + +In v4, the flat `event.detail` properties were reorganized into a unified `ctx` object: + +| v2 | v4 | +|----|-----| +| `event.detail.elt` | `event.detail.ctx.sourceElement` | +| `event.detail.path` | `event.detail.ctx.request.action` | +| `event.detail.target` | `event.detail.ctx.target` | + +### Examples affected + +**PicoBusy** updated event names and detail path: +```python +def PicoBusy(htmx4=False, metaChar=None): + if metaChar is None: metaChar = '-' if htmx4 else ':' + evt = (f'before{metaChar}request', f'after{metaChar}request') if htmx4 else ('beforeRequest', 'afterRequest') + elt = 'event.detail.ctx.sourceElement' if htmx4 else 'event.detail.elt' + return (HtmxOn(evt[0], f"{elt}.setAttribute('aria-busy', 'true' )", htmx4=htmx4), + HtmxOn(evt[1], f"{elt}.setAttribute('aria-busy', 'false')", htmx4=htmx4)) +``` + +--- + +## Partial + +htmx v4 introduces `HxPartial` (``) to update elements **outside the primary swap target**. Each `HxPartial` specifies its own `hx_target`, making targeting more explicit than OOB. `hx_swap_oob=True` still works in v4 for simple same-ID replacement. + +```python +# Update primary target + another element +return Div("main content"), HxPartial(Div("sidebar update"), hx_target="#sidebar") +``` + +**Swap ordering changed:** +- **v2:** OOB swaps → primary swap +- **v4:** Primary swap → OOB swaps → partials + +--- + +## Attribute Inheritance + +In v2, attributes like `hx-target`, `hx-swap`, `hx-boost` inherited implicitly from parent elements. In v4, inheritance is **off by default** — use the `:inherited` modifier. With `metaCharacter="-"`, this becomes `_inherited` in FastHTML: + +```python +# v2: children inherit hx_target from parent automatically +Div(hx_target="#output")( + Button("A", hx_get="/a"), + Button("B", hx_get="/b"), +) + +# v4: must opt in to inheritance +Div(hx_target_inherited="#output")( + Button("A", hx_get="/a"), + Button("B", hx_get="/b"), +) +``` + +--- + +## Request Headers + +In v4, the request headers sent by the browser changed: + +Sources: [htmx v4 migration guide: Request headers](https://four.htmx.org/docs/get-started/migration#request-headers), [htmx upgrade guide: Step 8 server-side header handling](https://github.com/bigskysoftware/htmx/blob/four/src/skills/htmx-upgrade-from-htmx2.md#step-8-update-server-side-header-handling) + +| v2 Header | v4 Header | Notes | +|-----------|-----------|-------| +| `HX-Trigger` | `HX-Source` | Format changed to `tag#id` | +| `HX-Target` | `HX-Target` | Format changed to `tag#id` | +| `HX-Trigger-Name` | *(removed)* | Use `HX-Source` | +| `HX-Prompt` | *(removed)* | Use `hx-confirm` with `js:` prefix | +| *(n/a)* | `HX-Request-Type` | `"partial"` or `"full"` | +| *(n/a)* | `Accept` | Now explicitly `text/html` | + +In FastHTML handler signatures: + +```python +# v2 +async def handle(hx_trigger: str): ... + +# v4 +async def handle(hx_source: str): ... +``` + +Or use the `HtmxHeaders` dataclass (works in both versions): + +```python +async def handle(htmx: HtmxHeaders): + print(htmx.source, htmx.target, htmx.request_type) +``` + +**Note:** `hx-trigger` the *HTML attribute* is unchanged, only the *request header* was renamed. + +--- + +## WebSocket + +### Attribute Changes + +| v2 | v4 | Notes | +|----|-----|-------| +| `hx_ext='ws'` | *(remove)* | Extension auto-registers when script is loaded | +| `ws_connect='/endpoint'` | `hx_ws_connect='/endpoint'` | | +| `ws_send=True` | `hx_ws_send=True` | | + +```python +app, rt = fast_app(htmx4=True, exts='ws') + +@rt('/') +def get(): + return Titled('WS Demo', + Div(id='msgs'), + Form(Input(id='msg', name='msg'), + hx_ws_send=True), + hx_ws_connect='/ws') + +@app.ws('/ws') +async def ws(msg: str, send): + await send(Div(f'You said: {msg}', + id='msgs', hx_swap_oob=True)) # v2: no hx_swap_oob needed +``` + +### Data Structure + +| v2 | v4 | +|----|-----| +| Form fields at root: `data['msg']` | Nested under `values`: `data['values']['msg']` | +| Headers as `data['HEADERS']` | Headers as `data['headers']` (lowercase) | + +Although htmx v4 WS supports a JSON envelope format for server→client messages too, FastHTML sends raw HTML for compatibility with htmx v2. + +--- + +## SSE + +### Attribute Changes + +| v2 | v4 | +|----|-----| +| `sse_connect='/endpoint'` | `hx_sse_connect='/endpoint'` | +| `sse_swap='message'` | *(remove)* | + +```python +# v2 +Div(hx_ext="sse", sse_connect="/stream", sse_swap="message") + +# v4 +Div(hx_sse_connect="/stream") +``` + +### `sse_message` + +In v4, the `event:` line must be omitted for default swaps — including it triggers a custom DOM event instead. Pass `htmx4=True`: + +```python +# v2 +yield sse_message(data) # sends "event: message\ndata: ...\n\n" + +# v4 +yield sse_message(data, htmx4=True) # sends "data: ...\n\n" (no event line) +``` + +For updating multiple targets (multiplexing), use `HxPartial` in a single message: + +```python +yield sse_message( + (HxPartial(content_a, hx_target="#a"), HxPartial(content_b, hx_target="#b")), + htmx4=True) +``` diff --git a/htmxv4_migration/partial.ipynb b/htmxv4_migration/partial.ipynb new file mode 100644 index 00000000..18b71fc3 --- /dev/null +++ b/htmxv4_migration/partial.ipynb @@ -0,0 +1,135 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "8cf2529f", + "metadata": {}, + "source": [ + "# OOB swaps demo — htmx v4" + ] + }, + { + "cell_type": "markdown", + "id": "9a836bed", + "metadata": { + "time_run": "2026-02-22T04:44:21.435394+00:00" + }, + "source": [ + "## Changes from htmx v2 (`oob.py`)\n", + "\n", + "**1. App init:** `fast_app(htmx=False, htmx4=True)` — loads htmx v4 instead of v2.\n", + "\n", + "**2. Selector-based OOB → `Partial`:** \n", + "In v2, you could write `hx_swap_oob='afterend:#first'` to target an arbitrary element with a CSS selector. In v4, it's recommended to use `` tags, which are more explicit:\n", + "\n", + "| v2 | v4 |\n", + "|----|-----|\n", + "| `Div(P('thing C'), hx_swap_oob='afterend:#first')` | `Partial(P('thing C'), hx_target='#first', hx_swap='afterend')` |\n", + "| `Div(P('thing F'), hx_swap_oob='innerHTML', id='second')` | `Partial(P('thing F'), hx_target='#second', hx_swap='innerHTML')` |\n", + "\n", + "**3. Plain ID OOB unchanged:** \n", + "`P('thing J', hx_swap_oob='true', id='third')` is kept as-is. v4 still recommends `hx_swap_oob=True` for simple same-element ID replacement.\n", + "\n", + "**4. Swap ordering difference:** \n", + "v2 processes OOB swaps before the primary swap. v4 processes: primary → OOB → partials. This means elements inserted with `afterend` may appear in a different order relative to the primary content when migrating." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "b3d83288", + "metadata": { + "time_run": "2026-02-22T04:47:24.740340+00:00" + }, + "outputs": [], + "source": [ + "from fasthtml.common import *\n", + "from fasthtml.jupyter import *" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "daeb2701", + "metadata": { + "time_run": "2026-02-22T04:47:24.829813+00:00" + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "app,rt = fast_app(htmx=False, htmx4=True)\n", + "\n", + "@rt\n", + "def link():\n", + " return (\n", + " P('thing A'),\n", + " P('thing B'),\n", + " HxPartial(P('thing C'), hx_target='#first', hx_swap='afterend'),\n", + " HxPartial(P('thing D'), hx_target='#first', hx_swap='afterend'),\n", + " HxPartial(P('thing E'), hx_target='#first', hx_swap='afterend'),\n", + " HxPartial(P('thing F'), hx_target='#second', hx_swap='innerHTML'),\n", + " HxPartial(P('thing G'), hx_target='#second', hx_swap='beforeend'),\n", + " HxPartial(P('thing H'), hx_target='#second', hx_swap='beforeend'),\n", + " HxPartial(P('thing I'), hx_target='#second', hx_swap='beforeend'),\n", + " P('thing J', hx_swap_oob='true', id='third'), # plain ID swap, oob is fine\n", + " )\n", + "\n", + "@rt\n", + "def index():\n", + " cts = (\n", + " Button('click', hx_target='#first', hx_get=link, hx_swap='afterend'),\n", + " Grid(\n", + " Div( H3('first'), Div(id='first' ) ),\n", + " Div( H3('second'), Div(id='second') ),\n", + " Div( H3('third'), Div(id='third' ) )\n", + " )\n", + " )\n", + " return Titled('HTMX swaps demo', *cts)\n", + "\n", + "srv = JupyUvi(app)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "fasthtml (3.12.8)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.8" + }, + "solveit_dialog_mode": "learning", + "solveit_ver": 2 + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/htmxv4_migration/request_headers.ipynb b/htmxv4_migration/request_headers.ipynb new file mode 100644 index 00000000..2a0d9a7a --- /dev/null +++ b/htmxv4_migration/request_headers.ipynb @@ -0,0 +1,102 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "8db3933f", + "metadata": { + "time_run": "2026-02-24T21:11:55.642796+00:00" + }, + "source": [ + "# ws_no4 — Simple click-to-post (htmx v4)\n", + "\n", + "### Migration changes from v2 to v4\n", + "\n", + "| Change | v2 | v4 |\n", + "|--------|----|----|\n", + "| App setup | `fast_app()` | `fast_app(htmx4=True)` |\n", + "| Request header | `HX-Trigger` (just id: `textid`) | `HX-Source` (tag#id format: `p#textid`) |\n", + "| Handler param | `hx_trigger: str` | `hx_source: str` |" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "0c411225", + "metadata": { + "time_run": "2026-02-25T20:50:59.963105+00:00" + }, + "outputs": [], + "source": [ + "from fasthtml.common import *\n", + "from fasthtml.jupyter import *" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "41f0a90c", + "metadata": { + "time_run": "2026-02-25T20:51:01.172724+00:00" + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "app, rt = fast_app(htmx4=True)\n", + "\n", + "@rt\n", + "def index():\n", + " return Div(\n", + " P(id=\"textid\", name=\"textname\", hx_post=handle, hx_target='#dest')(\n", + " \"Click Me\"),\n", + " P(\"Text to Update\", id=\"dest\"))\n", + "\n", + "@rt\n", + "async def handle(hx_source:str): return Div(f\"source: {hx_source}\")\n", + "\n", + "srv = JupyUvi(app)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "fasthtml (3.12.8)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.8" + }, + "solveit_dialog_mode": "learning", + "solveit_ver": 2 + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/htmxv4_migration/sse.ipynb b/htmxv4_migration/sse.ipynb new file mode 100644 index 00000000..4961f6a8 --- /dev/null +++ b/htmxv4_migration/sse.ipynb @@ -0,0 +1,143 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "a5f43bb5", + "metadata": {}, + "source": [ + "# SSE Clock Plus - HTMX v4" + ] + }, + { + "cell_type": "markdown", + "id": "776a135e", + "metadata": {}, + "source": [ + "## What this app does\n", + "\n", + "This app displays a live-updating clock and a counter, both powered by a single SSE (Server-Sent Events) stream.\n", + "\n", + "## htmx v2 approach\n", + "\n", + "The original htmx v2 version (`sse_clock_plus.py`) uses a **custom named event** (`TimeUpdateEvent`) for SSE swapping:\n", + "\n", + "- The server sends `event: TimeUpdateEvent\\ndata: ...\\n\\n`\n", + "- The client uses `sse_swap=\"TimeUpdateEvent\"` to swap content when that specific event arrives\n", + "- This pattern enables **multiplexing**: a single SSE connection can dispatch different events to different elements using `sse_swap=\"EventA\"`, `sse_swap=\"EventB\"`, etc.\n", + "\n", + "## htmx v4 approach\n", + "\n", + "In htmx v4, SSE uses the `hx-sse` extension (`exts='sse'` in `fast_app`). The semantics of named events changed:\n", + "\n", + "- **Named events** (`event: ...`) now trigger a **DOM event** instead of performing a swap. This is useful for JavaScript-driven logic, not declarative swaps.\n", + "- **Unnamed events** (just `data:` lines) perform normal htmx swaps into the target.\n", + "\n", + "For dispatching content to multiple elements, htmx v4 introduces **``** tags. Each partial specifies its own `hx-target`, so a single `data:` line can update multiple parts of the page:\n", + "\n", + "```html\n", + "12:30:45\n", + "42\n", + "```\n", + "\n", + "## Changes from v2\n", + "\n", + "1. **SSE extension via `exts='sse'`**, uses htmx v4's built-in `hx-sse` extension instead of the separate v2 extension script\n", + "2. **Standard htmx attributes**, use `hx_get=\"/time-sender\"` + `hx_trigger=\"load\"` to initiate the stream\n", + "3. **`` for multi-target updates**, each element targeted by its own partial, replacing the named event multiplexing pattern\n", + "4. **`sse_message(..., htmx4=True)`**, omits the `event:` line so htmx performs a normal swap" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "65c22b81", + "metadata": {}, + "outputs": [], + "source": [ + "from fasthtml.common import *\n", + "from fasthtml.jupyter import *\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "abd7eefd", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import asyncio\n", + "from datetime import datetime\n", + "\n", + "app, rt = fast_app(htmx=False, htmx4=True, exts='sse')\n", + "\n", + "@rt(\"/\")\n", + "def get():\n", + " return Titled(\"SSE Clock Plus\",\n", + " Div(hx_get=\"/time-sender\", hx_trigger=\"load\")(\n", + " Div(\"Time: \", Span(\"XX:XX\", id=\"time\")),\n", + " Div(\"Count: \", Span(\"0\", id=\"counter\"))\n", + " )\n", + " )\n", + "\n", + "async def time_generator():\n", + " counter = 0\n", + " while True:\n", + " _time = Span(datetime.now().strftime('%H:%M:%S'))\n", + " counter += 1\n", + " yield sse_message(\n", + " (HxPartial(_time, hx_target=\"#time\"), HxPartial(counter, hx_target=\"#counter\")),\n", + " htmx4=True)\n", + " await asyncio.sleep(1)\n", + "\n", + "@rt(\"/time-sender\")\n", + "async def get():\n", + " return EventStream(time_generator())\n", + "\n", + "srv = JupyUvi(app)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "fasthtml (3.12.8)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.8" + }, + "solveit_dialog_mode": "learning", + "solveit_ver": 2 + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/htmxv4_migration/websocket.ipynb b/htmxv4_migration/websocket.ipynb new file mode 100644 index 00000000..25c98683 --- /dev/null +++ b/htmxv4_migration/websocket.ipynb @@ -0,0 +1,106 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "e4fd184a", + "metadata": {}, + "source": [ + "# WebSocket Example (htmx v4)\n", + "\n", + "Migrated from `ws.py` (htmx v2).\n", + "\n", + "**Changes from v2 → v4:**\n", + "- `hx_ext=\"ws\"`: removed (extension auto-registers in v4)\n", + "- `ws_connect=\"/ws\"` → `hx_ws_connect=\"/ws\"`\n", + "- `ws_send=True` → `hx_ws_send=True`\n", + "- `hx_swap_oob=True` (must be explicit in ws htmxv4)" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "91880c17", + "metadata": {}, + "outputs": [], + "source": [ + "from fasthtml.common import *\n", + "from fasthtml.jupyter import *" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "6ec2d448", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "app, rt = fast_app(exts='ws', htmx4=True)\n", + "\n", + "@rt(\"/\")\n", + "def get():\n", + " return Div(\n", + " P(hx_ws_send=True, id=\"textid\", name=\"textname\", hx_ws_connect=\"/ws\")(\n", + " \"Click Me\"),\n", + " P(\"Text to Update\", id=\"dest\")\n", + " )\n", + "\n", + "@app.ws(\"/ws\")\n", + "async def ws(ws, send, hx_source:str):\n", + " return Div(f\"trigger: {hx_source}\", id=\"dest\", hx_swap_oob=True)\n", + "\n", + "srv = JupyUvi(app)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1e0b22f7", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "fasthtml (3.12.8)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.8" + }, + "solveit_dialog_mode": "learning", + "solveit_ver": 2 + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/nbs/api/04_pico.ipynb b/nbs/api/04_pico.ipynb index f68175df..49e5a7b3 100644 --- a/nbs/api/04_pico.ipynb +++ b/nbs/api/04_pico.ipynb @@ -373,8 +373,8 @@ " if metaChar is None: metaChar = '-' if htmx4 else ':'\n", " evt = (f'before{metaChar}request', f'after{metaChar}request') if htmx4 else ('beforeRequest', 'afterRequest')\n", " elt = 'event.detail.ctx.sourceElement' if htmx4 else 'event.detail.elt'\n", - " return (HtmxOn(evt[0], f\"{elt}.setAttribute('aria-busy', 'true' )\", metaChar=metaChar),\n", - " HtmxOn(evt[1], f\"{elt}.setAttribute('aria-busy', 'false')\", metaChar=metaChar))" + " return (HtmxOn(evt[0], f\"{elt}.setAttribute('aria-busy', 'true' )\", htmx4=htmx4),\n", + " HtmxOn(evt[1], f\"{elt}.setAttribute('aria-busy', 'false')\", htmx4=htmx4))" ] }, { From 192705e248cceb393806f96b04c83466cc066cc1 Mon Sep 17 00:00:00 2001 From: Dien-Hoa Date: Sun, 12 Apr 2026 22:29:35 -0400 Subject: [PATCH 10/19] typo migration doc --- htmxv4_migration/fasthtml_htmxv4_migration.md | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/htmxv4_migration/fasthtml_htmxv4_migration.md b/htmxv4_migration/fasthtml_htmxv4_migration.md index a5b872dc..ac1c9637 100644 --- a/htmxv4_migration/fasthtml_htmxv4_migration.md +++ b/htmxv4_migration/fasthtml_htmxv4_migration.md @@ -1,10 +1,10 @@ -# FastHTML upgrade from htmx2 to htmx4 +# FastHTML Upgrade from htmx2 to htmx4 -This document is for summarizing what one need to do to upgrade their fasthtml code base from htmxv2 to htmx v4. For the full htmx-level migration details, see: https://four.htmx.org/docs/get-started/migration +This document summarizes what you need to do to upgrade a FastHTML codebase from htmx v2 to htmx v4. For full htmx migration details, see: https://four.htmx.org/docs/get-started/migration ## What's New -FastHTML now supports htmx v4 via `htmx4=True`. Key changes at the FastHTML level: +FastHTML now supports htmx v4 via `htmx4=True`. Here are the key changes at the FastHTML level: | Area | htmx v2 (default) | htmx v4 (`htmx4=True`) | |------|-------------------|------------------------| @@ -25,7 +25,7 @@ FastHTML now supports htmx v4 via `htmx4=True`. Key changes at the FastHTML leve ### MetaCharacter -htmx v4 uses `:` as a separator in attribute and event names (e.g. `hx-ws:connect`, `htmx:before:request`). Since colons can't be used in Python keyword arguments, FastHTML sets `metaCharacter="-"` which replaces all `:` with `-`. +htmx v4 uses `:` as a separator in attribute and event names (for example, `hx-ws:connect` and `htmx:before:request`). Since colons cannot be used in Python keyword arguments, FastHTML sets `metaCharacter="-"`, which replaces all `:` characters with `-`. The full conversion chain: @@ -35,11 +35,11 @@ The full conversion chain: | With `metaCharacter="-"` | `htmx-before-request` | | FastHTML Python (`_` → `-`) | `htmx_before_request` | -This is configured automatically when you use `htmx4=True`. +This is configured automatically when you set `htmx4=True`. ### Renamed Events -All events follow a new pattern: `htmx:phase:action[:sub-action]` +All events now follow this pattern: `htmx:phase:action[:sub-action]` Sources: [htmx v4 migration guide: Renamed events](https://four.htmx.org/docs/get-started/migration#renamed-events), [htmx upgrade guide: Step 5 event listeners](https://github.com/bigskysoftware/htmx/blob/four/src/skills/htmx-upgrade-from-htmx2.md#step-5-update-event-listeners) @@ -101,7 +101,7 @@ def PicoBusy(htmx4=False, metaChar=None): ## Partial -htmx v4 introduces `HxPartial` (``) to update elements **outside the primary swap target**. Each `HxPartial` specifies its own `hx_target`, making targeting more explicit than OOB. `hx_swap_oob=True` still works in v4 for simple same-ID replacement. +htmx v4 introduces `HxPartial` (``) for updating elements **outside the primary swap target**. Each `HxPartial` specifies its own `hx_target`, which makes targeting more explicit than OOB. `hx_swap_oob=True` still works in v4 for simple same-ID replacement. ```python # Update primary target + another element @@ -116,7 +116,7 @@ return Div("main content"), HxPartial(Div("sidebar update"), hx_target="#sidebar ## Attribute Inheritance -In v2, attributes like `hx-target`, `hx-swap`, `hx-boost` inherited implicitly from parent elements. In v4, inheritance is **off by default** — use the `:inherited` modifier. With `metaCharacter="-"`, this becomes `_inherited` in FastHTML: +In v2, attributes like `hx-target`, `hx-swap`, and `hx-boost` were inherited implicitly from parent elements. In v4, inheritance is **off by default**; use the `:inherited` modifier instead. With `metaCharacter="-"`, this becomes `_inherited` in FastHTML: ```python # v2: children inherit hx_target from parent automatically @@ -166,7 +166,7 @@ async def handle(htmx: HtmxHeaders): print(htmx.source, htmx.target, htmx.request_type) ``` -**Note:** `hx-trigger` the *HTML attribute* is unchanged, only the *request header* was renamed. +**Note:** `hx-trigger`, the *HTML attribute*, is unchanged; only the *request header* was renamed. --- @@ -176,7 +176,7 @@ async def handle(htmx: HtmxHeaders): | v2 | v4 | Notes | |----|-----|-------| -| `hx_ext='ws'` | *(remove)* | Extension auto-registers when script is loaded | +| `hx_ext='ws'` | *(remove)* | The extension auto-registers when the script is loaded | | `ws_connect='/endpoint'` | `hx_ws_connect='/endpoint'` | | | `ws_send=True` | `hx_ws_send=True` | | @@ -204,7 +204,7 @@ async def ws(msg: str, send): | Form fields at root: `data['msg']` | Nested under `values`: `data['values']['msg']` | | Headers as `data['HEADERS']` | Headers as `data['headers']` (lowercase) | -Although htmx v4 WS supports a JSON envelope format for server→client messages too, FastHTML sends raw HTML for compatibility with htmx v2. +Although htmx v4 WebSocket support also includes a JSON envelope format for server-to-client messages, FastHTML sends raw HTML for compatibility with htmx v2. --- @@ -227,7 +227,7 @@ Div(hx_sse_connect="/stream") ### `sse_message` -In v4, the `event:` line must be omitted for default swaps — including it triggers a custom DOM event instead. Pass `htmx4=True`: +In v4, the `event:` line must be omitted for default swaps; including it triggers a custom DOM event instead. Pass `htmx4=True`: ```python # v2 From a79e4e7ac617c6f38cfa3158435ec2f014844afb Mon Sep 17 00:00:00 2001 From: Dien-Hoa Date: Mon, 13 Apr 2026 22:02:19 -0400 Subject: [PATCH 11/19] iframe_scr support htmxv4 --- fasthtml/core.py | 19 ++++++++++++++++--- nbs/api/00_core.ipynb | 18 +++++++++++++++--- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/fasthtml/core.py b/fasthtml/core.py index 4eed0b84..b86d46bb 100644 --- a/fasthtml/core.py +++ b/fasthtml/core.py @@ -569,11 +569,24 @@ def def_hdrs(htmx=True, htmx4=False, surreal=True): function sendmsg() { window.parent.postMessage({height: document.documentElement.offsetHeight}, '*'); } + window.onload = function() { sendmsg(); - document.body.addEventListener('htmx:afterSettle', sendmsg); - document.body.addEventListener('htmx:wsAfterMessage', sendmsg); - };""")) + + const ver = window.htmx?.version || ''; + const isV4 = ver.startsWith('4.'); + const mc = window.htmx?.config?.metaCharacter || ':'; + + if (isV4) { + document.body.addEventListener(`htmx${mc}after${mc}swap`, sendmsg); + document.body.addEventListener(`htmx${mc}after${mc}ws${mc}message`, sendmsg); + } else { + document.body.addEventListener('htmx:afterSettle', sendmsg); + document.body.addEventListener('htmx:wsAfterMessage', sendmsg); + } + }; +""")) + # %% ../nbs/api/00_core.ipynb #17ced9a3 class _LifespanCtx: diff --git a/nbs/api/00_core.ipynb b/nbs/api/00_core.ipynb index 1bed46eb..5f94138b 100644 --- a/nbs/api/00_core.ipynb +++ b/nbs/api/00_core.ipynb @@ -1769,11 +1769,23 @@ " function sendmsg() {\n", " window.parent.postMessage({height: document.documentElement.offsetHeight}, '*');\n", " }\n", + "\n", " window.onload = function() {\n", " sendmsg();\n", - " document.body.addEventListener('htmx:afterSettle', sendmsg);\n", - " document.body.addEventListener('htmx:wsAfterMessage', sendmsg);\n", - " };\"\"\"))" + "\n", + " const ver = window.htmx?.version || '';\n", + " const isV4 = ver.startsWith('4.');\n", + " const mc = window.htmx?.config?.metaCharacter || ':';\n", + "\n", + " if (isV4) {\n", + " document.body.addEventListener(`htmx${mc}after${mc}swap`, sendmsg);\n", + " document.body.addEventListener(`htmx${mc}after${mc}ws${mc}message`, sendmsg);\n", + " } else {\n", + " document.body.addEventListener('htmx:afterSettle', sendmsg);\n", + " document.body.addEventListener('htmx:wsAfterMessage', sendmsg);\n", + " }\n", + " };\n", + "\"\"\"))\n" ] }, { From b709bf61416c4cd641b0bd4e77ac154ee4ae844b Mon Sep 17 00:00:00 2001 From: Dien-Hoa Date: Tue, 14 Apr 2026 21:47:39 -0400 Subject: [PATCH 12/19] update htmx_config_port with htmx4 --- fasthtml/jupyter.py | 25 +++++---- nbs/api/06_jupyter.ipynb | 112 +++++++-------------------------------- 2 files changed, 35 insertions(+), 102 deletions(-) diff --git a/fasthtml/jupyter.py b/fasthtml/jupyter.py index 9d053082..07426ccb 100644 --- a/fasthtml/jupyter.py +++ b/fasthtml/jupyter.py @@ -70,15 +70,22 @@ def _repr_html_(self:FT): return to_xml(Div(self, scr_proc, **kw)) # %% ../nbs/api/06_jupyter.ipynb #1daaa0e1 -def htmx_config_port(port=8000): - display(HTML(''' +def htmx_config_port(port=8000, htmx4=False): + evt = 'htmx-config-request' if htmx4 else 'htmx:configRequest' + + display(HTML(f''' ''' % port)) +document.body.addEventListener('{evt}', (event) => {{ + const path = {'event.detail.ctx.request.action' if htmx4 else 'event.detail.path'}; + if (path.includes('://')) return; + + const full = `${{location.protocol}}//${{location.hostname}}:{port}${{path}}`; + + {'event.detail.ctx.request.mode = "cors";' if htmx4 else 'htmx.config.selfRequestsOnly = false;'} + {'event.detail.ctx.request.action = full;' if htmx4 else 'event.detail.path = full;'} +}}); +''')) + # %% ../nbs/api/06_jupyter.ipynb #29a834a5 class JupyUvi: @@ -88,7 +95,7 @@ def __init__(self, app, log_level="error", host='0.0.0.0', port=8000, start=True store_attr(but='start') self.server = None if start: self.start() - if not os.environ.get('IN_SOLVEIT'): htmx_config_port(port) + if not os.environ.get('IN_SOLVEIT'): htmx_config_port(port, htmx4=app.htmx4) def start(self): self.server = nb_serve(self.app, log_level=self.log_level, host=self.host, port=self.port, **self.kwargs) diff --git a/nbs/api/06_jupyter.ipynb b/nbs/api/06_jupyter.ipynb index 00f4be40..1699a284 100644 --- a/nbs/api/06_jupyter.ipynb +++ b/nbs/api/06_jupyter.ipynb @@ -174,15 +174,21 @@ "outputs": [], "source": [ "#| export\n", - "def htmx_config_port(port=8000):\n", - " display(HTML('''\n", + "def htmx_config_port(port=8000, htmx4=False):\n", + " evt = 'htmx-config-request' if htmx4 else 'htmx:configRequest'\n", + "\n", + " display(HTML(f'''\n", "''' % port))" + "document.body.addEventListener('{evt}', (event) => {{\n", + " const path = {'event.detail.ctx.request.action' if htmx4 else 'event.detail.path'};\n", + " if (path.includes('://')) return;\n", + "\n", + " const full = `${{location.protocol}}//${{location.hostname}}:{port}${{path}}`;\n", + "\n", + " {'event.detail.ctx.request.mode = \"cors\";' if htmx4 else 'htmx.config.selfRequestsOnly = false;'}\n", + " {'event.detail.ctx.request.action = full;' if htmx4 else 'event.detail.path = full;'}\n", + "}});\n", + "'''))\n" ] }, { @@ -200,7 +206,7 @@ " store_attr(but='start')\n", " self.server = None\n", " if start: self.start()\n", - " if not os.environ.get('IN_SOLVEIT'): htmx_config_port(port)\n", + " if not os.environ.get('IN_SOLVEIT'): htmx_config_port(port, htmx4=app.htmx4)\n", "\n", " def start(self):\n", " self.server = nb_serve(self.app, log_level=self.log_level, host=self.host, port=self.port, **self.kwargs)\n", @@ -226,27 +232,7 @@ "execution_count": null, "id": "0f4b31e9", "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - "" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "app = FastHTML()\n", "rt = app.route\n", @@ -303,27 +289,7 @@ "execution_count": null, "id": "97bfb966", "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - "" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "app = FastHTML()\n", "rt = app.route\n", @@ -390,27 +356,7 @@ "execution_count": null, "id": "959eb254", "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - "" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "server = JupyUviAsync(app, port=port)\n", "await server.start()" @@ -520,27 +466,7 @@ "execution_count": null, "id": "78d40711", "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - "" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "server = JupyUvi(app, port=port)" ] From a9671bbf06caf9c34bd04e1f9173086a06dac2ef Mon Sep 17 00:00:00 2001 From: Dien-Hoa Date: Thu, 16 Apr 2026 21:28:43 -0400 Subject: [PATCH 13/19] hx4 beta2 --- fasthtml/core.py | 6 +++--- nbs/api/00_core.ipynb | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/fasthtml/core.py b/fasthtml/core.py index b86d46bb..97b89feb 100644 --- a/fasthtml/core.py +++ b/fasthtml/core.py @@ -496,14 +496,14 @@ async def _wrap_call(f, req, params): "remove-me": "https://cdn.jsdelivr.net/npm/htmx-ext-remove-me@2.0.0/remove-me.js", "debug": "https://unpkg.com/htmx.org@1.9.12/dist/ext/debug.js", "ws": "https://cdn.jsdelivr.net/npm/htmx-ext-ws@2.0.3/ws.js", - "ws4": "https://unpkg.com/htmx.org@4.0.0-beta1/dist/ext/hx-ws.js", + "ws4": "https://unpkg.com/htmx.org@4.0.0-beta2/dist/ext/hx-ws.js", "chunked-transfer": "https://cdn.jsdelivr.net/npm/htmx-ext-transfer-encoding-chunked@0.4.0/transfer-encoding-chunked.js", - "sse4": "https://unpkg.com/htmx.org@4.0.0-beta1/dist/ext/hx-sse.js" + "sse4": "https://unpkg.com/htmx.org@4.0.0-beta2/dist/ext/hx-sse.js" } # %% ../nbs/api/00_core.ipynb #60cb52ea htmxsrc = Script(src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.js") -htmx4src = Script(src="https://unpkg.com/htmx.org@4.0.0-beta1/dist/htmx.js") +htmx4src = Script(src="https://unpkg.com/htmx.org@4.0.0-beta2/dist/htmx.js") fhjsscr = Script(src="https://cdn.jsdelivr.net/gh/answerdotai/fasthtml-js@1.0.12/fasthtml.js") surrsrc = Script(src="https://cdn.jsdelivr.net/gh/answerdotai/surreal@main/surreal.js") scopesrc = Script(src="https://cdn.jsdelivr.net/gh/gnat/css-scope-inline@main/script.js") diff --git a/nbs/api/00_core.ipynb b/nbs/api/00_core.ipynb index 5f94138b..d96919c7 100644 --- a/nbs/api/00_core.ipynb +++ b/nbs/api/00_core.ipynb @@ -1565,9 +1565,9 @@ " \"remove-me\": \"https://cdn.jsdelivr.net/npm/htmx-ext-remove-me@2.0.0/remove-me.js\",\n", " \"debug\": \"https://unpkg.com/htmx.org@1.9.12/dist/ext/debug.js\",\n", " \"ws\": \"https://cdn.jsdelivr.net/npm/htmx-ext-ws@2.0.3/ws.js\",\n", - " \"ws4\": \"https://unpkg.com/htmx.org@4.0.0-beta1/dist/ext/hx-ws.js\",\n", + " \"ws4\": \"https://unpkg.com/htmx.org@4.0.0-beta2/dist/ext/hx-ws.js\",\n", " \"chunked-transfer\": \"https://cdn.jsdelivr.net/npm/htmx-ext-transfer-encoding-chunked@0.4.0/transfer-encoding-chunked.js\",\n", - " \"sse4\": \"https://unpkg.com/htmx.org@4.0.0-beta1/dist/ext/hx-sse.js\"\n", + " \"sse4\": \"https://unpkg.com/htmx.org@4.0.0-beta2/dist/ext/hx-sse.js\"\n", "}" ] }, @@ -1606,7 +1606,7 @@ "source": [ "#| export\n", "htmxsrc = Script(src=\"https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.js\")\n", - "htmx4src = Script(src=\"https://unpkg.com/htmx.org@4.0.0-beta1/dist/htmx.js\")\n", + "htmx4src = Script(src=\"https://unpkg.com/htmx.org@4.0.0-beta2/dist/htmx.js\")\n", "fhjsscr = Script(src=\"https://cdn.jsdelivr.net/gh/answerdotai/fasthtml-js@1.0.12/fasthtml.js\")\n", "surrsrc = Script(src=\"https://cdn.jsdelivr.net/gh/answerdotai/surreal@main/surreal.js\")\n", "scopesrc = Script(src=\"https://cdn.jsdelivr.net/gh/gnat/css-scope-inline@main/script.js\")\n", From 7e49e700aba626bd51b32ce5097ef3a3ee41802a Mon Sep 17 00:00:00 2001 From: Dien-Hoa Date: Tue, 12 May 2026 21:23:45 -0400 Subject: [PATCH 14/19] htmx v4 alpha3 --- fasthtml/core.py | 7 +- htmxv4_migration/hx_live.ipynb | 631 +++++++++++++++++++++++++++++++++ nbs/api/00_core.ipynb | 7 +- 3 files changed, 639 insertions(+), 6 deletions(-) create mode 100644 htmxv4_migration/hx_live.ipynb diff --git a/fasthtml/core.py b/fasthtml/core.py index 97b89feb..278fbee0 100644 --- a/fasthtml/core.py +++ b/fasthtml/core.py @@ -496,14 +496,15 @@ async def _wrap_call(f, req, params): "remove-me": "https://cdn.jsdelivr.net/npm/htmx-ext-remove-me@2.0.0/remove-me.js", "debug": "https://unpkg.com/htmx.org@1.9.12/dist/ext/debug.js", "ws": "https://cdn.jsdelivr.net/npm/htmx-ext-ws@2.0.3/ws.js", - "ws4": "https://unpkg.com/htmx.org@4.0.0-beta2/dist/ext/hx-ws.js", + "ws4": "https://unpkg.com/htmx.org@4.0.0-beta3/dist/ext/hx-ws.js", "chunked-transfer": "https://cdn.jsdelivr.net/npm/htmx-ext-transfer-encoding-chunked@0.4.0/transfer-encoding-chunked.js", - "sse4": "https://unpkg.com/htmx.org@4.0.0-beta2/dist/ext/hx-sse.js" + "sse4": "https://unpkg.com/htmx.org@4.0.0-beta3/dist/ext/hx-sse.js", + "live": "https://unpkg.com/htmx.org@4.0.0-beta3/dist/ext/hx-live.js" } # %% ../nbs/api/00_core.ipynb #60cb52ea htmxsrc = Script(src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.js") -htmx4src = Script(src="https://unpkg.com/htmx.org@4.0.0-beta2/dist/htmx.js") +htmx4src = Script(src="https://unpkg.com/htmx.org@4.0.0-beta3/dist/htmx.js") fhjsscr = Script(src="https://cdn.jsdelivr.net/gh/answerdotai/fasthtml-js@1.0.12/fasthtml.js") surrsrc = Script(src="https://cdn.jsdelivr.net/gh/answerdotai/surreal@main/surreal.js") scopesrc = Script(src="https://cdn.jsdelivr.net/gh/gnat/css-scope-inline@main/script.js") diff --git a/htmxv4_migration/hx_live.ipynb b/htmxv4_migration/hx_live.ipynb new file mode 100644 index 00000000..21e47c8e --- /dev/null +++ b/htmxv4_migration/hx_live.ipynb @@ -0,0 +1,631 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "dc8afc4d", + "metadata": {}, + "outputs": [], + "source": [ + "from fasthtml.common import *\n", + "from fasthtml.jupyter import *" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "5805e37f", + "metadata": {}, + "outputs": [], + "source": [ + "# hx_live_hdr = Script(src='https://cdn.jsdelivr.net/npm/htmx.org@next/dist/ext/hx-live.min.js')" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "c41f3a07", + "metadata": {}, + "outputs": [], + "source": [ + "app, rt = fast_app(htmx=False, htmx4=True, exts='live')" + ] + }, + { + "cell_type": "markdown", + "id": "5eac14f4", + "metadata": {}, + "source": [ + "# Trigger" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "6ec2d448", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "@rt(\"/\")\n", + "def get():\n", + " i = Input(id=\"name\", value=\"world\")\n", + " o = Output(hx_live=\"this.textContent='hello, ' + q('#name').value\")\n", + " return Div(i, o, hx_ext=\"hx-live\")\n", + "\n", + "srv = JupyUvi(app)" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "1e0b22f7", + "metadata": {}, + "outputs": [], + "source": [ + "srv.stop()" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "37103e9f", + "metadata": {}, + "outputs": [], + "source": [ + "@rt(\"/\")\n", + "def get():\n", + " i = CheckboxX(id=\"tick\", value=\"tick\")\n", + " o = Output(\n", + " hx_live=\"\"\"\n", + " let term = q('#tick').checked;\n", + " if (term) {\n", + " this.textContent = \"Hello\";\n", + " } else {\n", + " this.textContent= '';\n", + " }\n", + "\n", + " return;\n", + " \"\"\"\n", + " )\n", + " return Div(i), Div(o)" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "0fa5e253", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "srv = JupyUvi(app)" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "8249ff2e", + "metadata": {}, + "outputs": [], + "source": [ + "srv.stop()" + ] + }, + { + "cell_type": "markdown", + "id": "65991702", + "metadata": {}, + "source": [ + "# Swap Coordination" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "7cee8e6a", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "@rt(\"/\")\n", + "def get():\n", + " total = Output(\n", + " \"0\",\n", + " id=\"total\",\n", + " hx_live=\"\"\"\n", + " let nums = q('.num').arr().map(x => Number(x.value || 0));\n", + " this.textContent = nums.reduce((a, b) => a + b, 0);\n", + " \"\"\"\n", + " )\n", + " return Div(\n", + " Button(\"Load numbers\", hx_get=\"/numbers\", hx_target=\"#numbers\", hx_swap=\"innerHTML\"),\n", + " Div(id=\"numbers\"),\n", + " P(\"Total: \", total),\n", + " )\n", + "\n", + "@rt(\"/numbers\")\n", + "def get():\n", + " return Div(\n", + " Input(type=\"number\", cls=\"num\", value=\"10\"),\n", + " Input(type=\"number\", cls=\"num\", value=\"20\"),\n", + " Input(type=\"number\", cls=\"num\", value=\"30\"),\n", + " )\n", + "\n", + "srv = JupyUvi(app)" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "3a06bcb6", + "metadata": {}, + "outputs": [], + "source": [ + "srv.stop()" + ] + }, + { + "cell_type": "markdown", + "id": "e8863e48", + "metadata": {}, + "source": [ + "# Scope Helpers" + ] + }, + { + "cell_type": "markdown", + "id": "0ea9138d", + "metadata": {}, + "source": [ + "Timeout: it's not cancel so lots of triggering will create a long queues" + ] + }, + { + "cell_type": "code", + "execution_count": 34, + "id": "26e11508", + "metadata": {}, + "outputs": [], + "source": [ + "@rt(\"/\")\n", + "def get():\n", + " i = Input(id=\"name\", value=\"world\")\n", + " o = Output(\n", + " hx_live=\"\"\"\n", + " await timeout(3000);\n", + " this.textContent='hello, ' + q('#name').value;\n", + " \"\"\"\n", + " )\n", + " return Div(i, o, hx_ext=\"hx-live\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "983bcd4c", + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "srv = JupyUvi(app)" + ] + }, + { + "cell_type": "code", + "execution_count": 35, + "id": "529cb558", + "metadata": {}, + "outputs": [], + "source": [ + "srv.stop()" + ] + }, + { + "cell_type": "markdown", + "id": "9230658c", + "metadata": {}, + "source": [ + "Bounce" + ] + }, + { + "cell_type": "code", + "execution_count": 36, + "id": "8ef2e1e9", + "metadata": {}, + "outputs": [], + "source": [ + "@rt(\"/\")\n", + "def get():\n", + " i = Input(id=\"name\", value=\"world\")\n", + " o = Output(\n", + " hx_live=\"\"\"\n", + " await debounce(3000);\n", + " this.textContent='hello, ' + q('#name').value;\n", + " \"\"\"\n", + " )\n", + " return Div(i, o, hx_ext=\"hx-live\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": 37, + "id": "02c11b30", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "\n", + "srv = JupyUvi(app)" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "ed376bd2", + "metadata": {}, + "outputs": [], + "source": [ + "srv.stop()" + ] + }, + { + "cell_type": "markdown", + "id": "dfa746a3", + "metadata": {}, + "source": [ + "# Reproduce htmx examples using fasthtml" + ] + }, + { + "cell_type": "markdown", + "id": "10f5029a", + "metadata": {}, + "source": [ + "From `15-hx-live.md`" + ] + }, + { + "cell_type": "markdown", + "id": "83375659", + "metadata": {}, + "source": [ + "### Derived value" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "31b8cb13", + "metadata": {}, + "outputs": [], + "source": [ + "@rt('/')\n", + "def get():\n", + " price = Label(\"Price: \", Input(id=\"price\", value=\"10\", type=\"number\"))\n", + " quantity = Label(\"Quantity: \", Input(id=\"qty\", value=\"3\", type=\"number\"))\n", + " o = Label(\n", + " \"Total:\",\n", + " P(hx_live=\"this.textContent = q('#price').valueAsNumber * q('#qty').valueAsNumber\")\n", + " )\n", + " return Div(price), Div(quantity), Div(o)" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "04233bca", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "srv = JupyUvi(app)" + ] + }, + { + "cell_type": "markdown", + "id": "14026b9d", + "metadata": {}, + "source": [ + "### Conditional class" + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "id": "2d0036d9", + "metadata": {}, + "outputs": [], + "source": [ + "@rt('/')\n", + "def get():\n", + " i = Label(\"Age: \", Input(id=\"age\", type=\"number\", value=\"0\"))\n", + " o = P(\n", + " \"Adult content\",\n", + " hx_live=\"this.classList.toggle('warn', q('#age').valueAsNumber < 18)\"\n", + " )\n", + " return Style(\".warn { color: red; font-weight: bold; }\"), Div(i), Div(o)" + ] + }, + { + "cell_type": "markdown", + "id": "ef42fbad", + "metadata": {}, + "source": [ + "### Live Filter" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "cfd80762", + "metadata": {}, + "outputs": [], + "source": [ + "@rt('/')\n", + "def get():\n", + " i = Input(id=\"filter\", placeholder=\"filter\")\n", + " ls = Ul(Li(\"apple\"), Li(\"apricot\", Li(\"banana\")))\n", + " o = Div(\n", + " hx_live=\"\"\"\n", + " let f = q('#filter').value.toLowerCase();\n", + " for (let li of q('li')) li.hidden = !li.textContent.toLowerCase().includes(f);\n", + " \"\"\"\n", + " )\n", + " return (Div(i), Div(ls), o)" + ] + }, + { + "cell_type": "markdown", + "id": "070732a0", + "metadata": {}, + "source": [ + "## Tab selection" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "481a332b", + "metadata": {}, + "outputs": [], + "source": [ + "@rt('/')\n", + "def get():\n", + " tab_script = \"take('selected', q('closest nav').q('button'))\"\n", + " return Div(\n", + " Style(\"\"\"\n", + " nav { display: flex; gap: .5rem; }\n", + " nav button { background: white; color: #333; }\n", + " nav button.selected { background: #111; color: white; }\n", + " \"\"\"),\n", + " Nav(\n", + " Button('A', cls='selected', hx_on_click=tab_script),\n", + " Button('B', hx_on_click=tab_script),\n", + " Button('C', hx_on_click=tab_script),\n", + " ),\n", + " hx_ext='hx-live'\n", + " )\n" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "041fa88b", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "srv = JupyUvi(app)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Debounced live search\n" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "41bbdba9", + "metadata": {}, + "outputs": [], + "source": [ + "@rt('/')\n", + "def get():\n", + " return Div(\n", + " Input(id='q', placeholder='search'),\n", + " Output(\n", + " hx_live=\"\"\"\n", + " let term = q('#q').value;\n", + " if (!term) { this.textContent = ''; return; }\n", + " await debounce(250);\n", + " this.textContent = await fetch('/search?q=' + encodeURIComponent(term))\n", + " .then(r => r.text());\n", + " \"\"\"\n", + " ),\n", + " hx_ext='hx-live'\n", + " )\n", + "\n", + "@rt('/search')\n", + "def get(q: str = ''):\n", + " items = ['apple', 'apricot', 'banana', 'blueberry', 'cherry']\n", + " matches = [item for item in items if q.lower() in item]\n", + " return ', '.join(matches) or 'No matches'\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "srv = JupyUvi(app)\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "fasthtml_v4 (3.12.8)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.8" + }, + "solveit_dialog_mode": "learning", + "solveit_ver": 2 + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/nbs/api/00_core.ipynb b/nbs/api/00_core.ipynb index d96919c7..1ad95142 100644 --- a/nbs/api/00_core.ipynb +++ b/nbs/api/00_core.ipynb @@ -1565,9 +1565,10 @@ " \"remove-me\": \"https://cdn.jsdelivr.net/npm/htmx-ext-remove-me@2.0.0/remove-me.js\",\n", " \"debug\": \"https://unpkg.com/htmx.org@1.9.12/dist/ext/debug.js\",\n", " \"ws\": \"https://cdn.jsdelivr.net/npm/htmx-ext-ws@2.0.3/ws.js\",\n", - " \"ws4\": \"https://unpkg.com/htmx.org@4.0.0-beta2/dist/ext/hx-ws.js\",\n", + " \"ws4\": \"https://unpkg.com/htmx.org@4.0.0-beta3/dist/ext/hx-ws.js\",\n", " \"chunked-transfer\": \"https://cdn.jsdelivr.net/npm/htmx-ext-transfer-encoding-chunked@0.4.0/transfer-encoding-chunked.js\",\n", - " \"sse4\": \"https://unpkg.com/htmx.org@4.0.0-beta2/dist/ext/hx-sse.js\"\n", + " \"sse4\": \"https://unpkg.com/htmx.org@4.0.0-beta3/dist/ext/hx-sse.js\",\n", + " \"live\": \"https://unpkg.com/htmx.org@4.0.0-beta3/dist/ext/hx-live.js\"\n", "}" ] }, @@ -1606,7 +1607,7 @@ "source": [ "#| export\n", "htmxsrc = Script(src=\"https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.js\")\n", - "htmx4src = Script(src=\"https://unpkg.com/htmx.org@4.0.0-beta2/dist/htmx.js\")\n", + "htmx4src = Script(src=\"https://unpkg.com/htmx.org@4.0.0-beta3/dist/htmx.js\")\n", "fhjsscr = Script(src=\"https://cdn.jsdelivr.net/gh/answerdotai/fasthtml-js@1.0.12/fasthtml.js\")\n", "surrsrc = Script(src=\"https://cdn.jsdelivr.net/gh/answerdotai/surreal@main/surreal.js\")\n", "scopesrc = Script(src=\"https://cdn.jsdelivr.net/gh/gnat/css-scope-inline@main/script.js\")\n", From ace2ea729adc15e6b0803a61d67e6aeca1fcf884 Mon Sep 17 00:00:00 2001 From: Dien-Hoa Date: Wed, 20 May 2026 16:08:45 -0400 Subject: [PATCH 15/19] update import pico --- htmxv4_migration/attribute_inheritance.ipynb | 17 ++++++++----- htmxv4_migration/event_structure.ipynb | 25 +++++++++++++++----- htmxv4_migration/partial.ipynb | 25 +++++++++++++++----- htmxv4_migration/request_headers.ipynb | 22 +++++++++++++---- htmxv4_migration/sse.ipynb | 22 +++++++++++++---- 5 files changed, 83 insertions(+), 28 deletions(-) diff --git a/htmxv4_migration/attribute_inheritance.ipynb b/htmxv4_migration/attribute_inheritance.ipynb index dff7d2f0..91034e08 100644 --- a/htmxv4_migration/attribute_inheritance.ipynb +++ b/htmxv4_migration/attribute_inheritance.ipynb @@ -14,7 +14,8 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, + "id": "6941a0f0", "metadata": {}, "outputs": [ { @@ -22,10 +23,14 @@ "text/html": [ "\n", "" ], @@ -79,7 +84,7 @@ ], "metadata": { "kernelspec": { - "display_name": "fasthtml (3.12.8)", + "display_name": "fasthtml_v4 (3.12.8)", "language": "python", "name": "python3" }, diff --git a/htmxv4_migration/event_structure.ipynb b/htmxv4_migration/event_structure.ipynb index 6c5851bc..0007dc44 100644 --- a/htmxv4_migration/event_structure.ipynb +++ b/htmxv4_migration/event_structure.ipynb @@ -22,7 +22,8 @@ "outputs": [], "source": [ "from fasthtml.common import *\n", - "from fasthtml.jupyter import *" + "from fasthtml.jupyter import *\n", + "from fasthtml.pico import PicoBusy" ] }, { @@ -36,10 +37,14 @@ "text/html": [ "\n", "" ], @@ -81,11 +86,19 @@ "\n", "srv = JupyUvi(app)" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "04a38604", + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { "kernelspec": { - "display_name": "fasthtml (3.12.8)", + "display_name": "fasthtml_v4 (3.12.8)", "language": "python", "name": "python3" }, diff --git a/htmxv4_migration/partial.ipynb b/htmxv4_migration/partial.ipynb index 18b71fc3..4f483768 100644 --- a/htmxv4_migration/partial.ipynb +++ b/htmxv4_migration/partial.ipynb @@ -44,7 +44,8 @@ "outputs": [], "source": [ "from fasthtml.common import *\n", - "from fasthtml.jupyter import *" + "from fasthtml.jupyter import *\n", + "from fasthtml.pico import Grid\n" ] }, { @@ -60,10 +61,14 @@ "text/html": [ "\n", "" ], @@ -107,11 +112,19 @@ "\n", "srv = JupyUvi(app)" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1852a4f4", + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { "kernelspec": { - "display_name": "fasthtml (3.12.8)", + "display_name": "fasthtml_v4 (3.12.8)", "language": "python", "name": "python3" }, diff --git a/htmxv4_migration/request_headers.ipynb b/htmxv4_migration/request_headers.ipynb index 2a0d9a7a..cdd1f85e 100644 --- a/htmxv4_migration/request_headers.ipynb +++ b/htmxv4_migration/request_headers.ipynb @@ -44,10 +44,14 @@ "text/html": [ "\n", "" ], @@ -74,11 +78,19 @@ "\n", "srv = JupyUvi(app)" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c949d496", + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { "kernelspec": { - "display_name": "fasthtml (3.12.8)", + "display_name": "fasthtml_v4 (3.12.8)", "language": "python", "name": "python3" }, diff --git a/htmxv4_migration/sse.ipynb b/htmxv4_migration/sse.ipynb index 4961f6a8..da2308ec 100644 --- a/htmxv4_migration/sse.ipynb +++ b/htmxv4_migration/sse.ipynb @@ -69,10 +69,14 @@ "text/html": [ "\n", "" ], @@ -115,11 +119,19 @@ "\n", "srv = JupyUvi(app)" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ae6b90aa", + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { "kernelspec": { - "display_name": "fasthtml (3.12.8)", + "display_name": "fasthtml_v4 (3.12.8)", "language": "python", "name": "python3" }, From f15f07cf39ccb063cbe1ca465dadc5687a8322b0 Mon Sep 17 00:00:00 2001 From: Dien-Hoa Date: Fri, 22 May 2026 13:05:43 -0400 Subject: [PATCH 16/19] bump htmx4 beta4 --- fasthtml/core.py | 8 ++++---- nbs/api/00_core.ipynb | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/fasthtml/core.py b/fasthtml/core.py index f49e3744..1b154229 100644 --- a/fasthtml/core.py +++ b/fasthtml/core.py @@ -500,15 +500,15 @@ async def _wrap_call(f, req, params): "remove-me": "https://cdn.jsdelivr.net/npm/htmx-ext-remove-me@2.0.0/remove-me.js", "debug": "https://unpkg.com/htmx.org@1.9.12/dist/ext/debug.js", "ws": "https://cdn.jsdelivr.net/npm/htmx-ext-ws@2.0.3/ws.js", - "ws4": "https://unpkg.com/htmx.org@4.0.0-beta3/dist/ext/hx-ws.js", + "ws4": "https://unpkg.com/htmx.org@4.0.0-beta4/dist/ext/hx-ws.js", "chunked-transfer": "https://cdn.jsdelivr.net/npm/htmx-ext-transfer-encoding-chunked@0.4.0/transfer-encoding-chunked.js", - "sse4": "https://unpkg.com/htmx.org@4.0.0-beta3/dist/ext/hx-sse.js", - "live": "https://unpkg.com/htmx.org@4.0.0-beta3/dist/ext/hx-live.js" + "sse4": "https://unpkg.com/htmx.org@4.0.0-beta4/dist/ext/hx-sse.js", + "live": "https://unpkg.com/htmx.org@4.0.0-beta4/dist/ext/hx-live.js" } # %% ../nbs/api/00_core.ipynb #60cb52ea htmxsrc = Script(src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.js") -htmx4src = Script(src="https://unpkg.com/htmx.org@4.0.0-beta3/dist/htmx.js") +htmx4src = Script(src="https://unpkg.com/htmx.org@4.0.0-beta4/dist/htmx.js") fhjsscr = Script(src="https://cdn.jsdelivr.net/gh/answerdotai/fasthtml-js@1.0.12/fasthtml.js") surrsrc = Script(src="https://cdn.jsdelivr.net/gh/answerdotai/surreal@main/surreal.js") scopesrc = Script(src="https://cdn.jsdelivr.net/gh/gnat/css-scope-inline@main/script.js") diff --git a/nbs/api/00_core.ipynb b/nbs/api/00_core.ipynb index 11b3f810..0482199e 100644 --- a/nbs/api/00_core.ipynb +++ b/nbs/api/00_core.ipynb @@ -1564,10 +1564,10 @@ " \"remove-me\": \"https://cdn.jsdelivr.net/npm/htmx-ext-remove-me@2.0.0/remove-me.js\",\n", " \"debug\": \"https://unpkg.com/htmx.org@1.9.12/dist/ext/debug.js\",\n", " \"ws\": \"https://cdn.jsdelivr.net/npm/htmx-ext-ws@2.0.3/ws.js\",\n", - " \"ws4\": \"https://unpkg.com/htmx.org@4.0.0-beta3/dist/ext/hx-ws.js\",\n", + " \"ws4\": \"https://unpkg.com/htmx.org@4.0.0-beta4/dist/ext/hx-ws.js\",\n", " \"chunked-transfer\": \"https://cdn.jsdelivr.net/npm/htmx-ext-transfer-encoding-chunked@0.4.0/transfer-encoding-chunked.js\",\n", - " \"sse4\": \"https://unpkg.com/htmx.org@4.0.0-beta3/dist/ext/hx-sse.js\",\n", - " \"live\": \"https://unpkg.com/htmx.org@4.0.0-beta3/dist/ext/hx-live.js\"\n", + " \"sse4\": \"https://unpkg.com/htmx.org@4.0.0-beta4/dist/ext/hx-sse.js\",\n", + " \"live\": \"https://unpkg.com/htmx.org@4.0.0-beta4/dist/ext/hx-live.js\"\n", "}" ] }, @@ -1606,7 +1606,7 @@ "source": [ "#| export\n", "htmxsrc = Script(src=\"https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.js\")\n", - "htmx4src = Script(src=\"https://unpkg.com/htmx.org@4.0.0-beta3/dist/htmx.js\")\n", + "htmx4src = Script(src=\"https://unpkg.com/htmx.org@4.0.0-beta4/dist/htmx.js\")\n", "fhjsscr = Script(src=\"https://cdn.jsdelivr.net/gh/answerdotai/fasthtml-js@1.0.12/fasthtml.js\")\n", "surrsrc = Script(src=\"https://cdn.jsdelivr.net/gh/answerdotai/surreal@main/surreal.js\")\n", "scopesrc = Script(src=\"https://cdn.jsdelivr.net/gh/gnat/css-scope-inline@main/script.js\")\n", From df92c4798f5b923e53b7a8ffa067a9ef4fc313d2 Mon Sep 17 00:00:00 2001 From: Dien-Hoa Date: Fri, 22 May 2026 22:41:31 -0400 Subject: [PATCH 17/19] add htmx4 attrs/evts in ft signature --- fasthtml/components.py | 31 +++++++++++++++++-------------- htmxv4_migration/websocket.ipynb | 14 +++++++++----- nbs/api/01_components.ipynb | 13 ++++++++++--- 3 files changed, 36 insertions(+), 22 deletions(-) diff --git a/fasthtml/components.py b/fasthtml/components.py index dab6ebdb..c9813718 100644 --- a/fasthtml/components.py +++ b/fasthtml/components.py @@ -3,18 +3,18 @@ # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/api/01_components.ipynb. # %% auto #0 -__all__ = ['named', 'html_attrs', 'hx_attrs', 'hx_evts', 'js_evts', 'hx_attrs_annotations', 'hx_evt_attrs', 'js_evt_attrs', - 'evt_attrs', 'HxPartial', 'attrmap_x', 'ft_html', 'ft_hx', 'File', 'show', 'fill_form', 'fill_dataclass', - 'find_inputs', 'html2ft', 'sse_message', 'A', 'Abbr', 'Address', 'Area', 'Article', 'Aside', 'Audio', 'B', - 'Base', 'Bdi', 'Bdo', 'Blockquote', 'Body', 'Br', 'Button', 'Canvas', 'Caption', 'Cite', 'Code', 'Col', - 'Colgroup', 'Data', 'Datalist', 'Dd', 'Del', 'Details', 'Dfn', 'Dialog', 'Div', 'Dl', 'Dt', 'Em', 'Embed', - 'Fencedframe', 'Fieldset', 'Figcaption', 'Figure', 'Footer', 'Form', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', - 'Head', 'Header', 'Hgroup', 'Hr', 'I', 'Iframe', 'Img', 'Input', 'Ins', 'Kbd', 'Label', 'Legend', 'Li', - 'Link', 'Main', 'Map', 'Mark', 'Menu', 'Meta', 'Meter', 'Nav', 'Noscript', 'Object', 'Ol', 'Optgroup', - 'Option', 'Output', 'P', 'Picture', 'PortalExperimental', 'Pre', 'Progress', 'Q', 'Rp', 'Rt', 'Ruby', 'S', - 'Samp', 'Script', 'Search', 'Section', 'Select', 'Slot', 'Small', 'Source', 'Span', 'Strong', 'Style', 'Sub', - 'Summary', 'Sup', 'Table', 'Tbody', 'Td', 'Template', 'Textarea', 'Tfoot', 'Th', 'Thead', 'Time', 'Title', - 'Tr', 'Track', 'U', 'Ul', 'Var', 'Video', 'Wbr'] +__all__ = ['named', 'html_attrs', 'hx_attrs', 'hx_attrs_4only', 'hx_evts', 'hx_evts_4only', 'js_evts', 'hx_attrs_annotations', + 'hx_evt_attrs', 'js_evt_attrs', 'evt_attrs', 'HxPartial', 'attrmap_x', 'ft_html', 'ft_hx', 'File', 'show', + 'fill_form', 'fill_dataclass', 'find_inputs', 'html2ft', 'sse_message', 'A', 'Abbr', 'Address', 'Area', + 'Article', 'Aside', 'Audio', 'B', 'Base', 'Bdi', 'Bdo', 'Blockquote', 'Body', 'Br', 'Button', 'Canvas', + 'Caption', 'Cite', 'Code', 'Col', 'Colgroup', 'Data', 'Datalist', 'Dd', 'Del', 'Details', 'Dfn', 'Dialog', + 'Div', 'Dl', 'Dt', 'Em', 'Embed', 'Fencedframe', 'Fieldset', 'Figcaption', 'Figure', 'Footer', 'Form', 'H1', + 'H2', 'H3', 'H4', 'H5', 'H6', 'Head', 'Header', 'Hgroup', 'Hr', 'I', 'Iframe', 'Img', 'Input', 'Ins', 'Kbd', + 'Label', 'Legend', 'Li', 'Link', 'Main', 'Map', 'Mark', 'Menu', 'Meta', 'Meter', 'Nav', 'Noscript', 'Object', + 'Ol', 'Optgroup', 'Option', 'Output', 'P', 'Picture', 'PortalExperimental', 'Pre', 'Progress', 'Q', 'Rp', + 'Rt', 'Ruby', 'S', 'Samp', 'Script', 'Search', 'Section', 'Select', 'Slot', 'Small', 'Source', 'Span', + 'Strong', 'Style', 'Sub', 'Summary', 'Sup', 'Table', 'Tbody', 'Td', 'Template', 'Textarea', 'Tfoot', 'Th', + 'Thead', 'Time', 'Title', 'Tr', 'Track', 'U', 'Ul', 'Var', 'Video', 'Wbr'] # %% ../nbs/api/01_components.ipynb #8e2d405b from dataclasses import dataclass, asdict, is_dataclass, make_dataclass, replace, astuple, MISSING @@ -44,10 +44,13 @@ def __add__(self:FT, b): return f'{self}{b}' named = set('a button form frame iframe img input map meta object param select textarea'.split()) html_attrs = 'id cls title style accesskey contenteditable dir draggable enterkeyhint hidden inert inputmode lang popover spellcheck tabindex translate'.split() hx_attrs = 'get post put delete patch trigger target swap swap_oob include select select_oob indicator push_url confirm disable replace_url vals disabled_elt ext headers history history_elt indicator inherit params preserve prompt replace_url request sync validate' +hx_attrs_4only = 'action method config ignore' # https://four.htmx.org/docs/get-started/migration#attributes hx_evts = 'abort afterOnLoad afterProcessNode afterRequest afterSettle afterSwap beforeCleanupElement beforeOnLoad beforeProcessNode beforeRequest beforeSwap beforeSend beforeTransition configRequest confirm historyCacheError historyCacheMiss historyCacheMissError historyCacheMissLoad historyRestore beforeHistorySave load noSSESourceError onLoadError oobAfterSwap oobBeforeSwap oobErrorNoTarget prompt pushedIntoHistory replacedInHistory responseError sendAbort sendError sseError sseOpen swapError targetError timeout validation:validate validation:failed validation:halted xhr:abort xhr:loadend xhr:loadstart xhr:progress' +# https://four.htmx.org/docs/get-started/migration#renamed-events +hx_evts_4only = 'after:init before:cleanup before:history:update before:init before:process before:history:restore after:history:push after:history:replace error' js_evts = "blur change contextmenu focus input invalid reset select submit keydown keypress keyup click dblclick mousedown mouseenter mouseleave mousemove mouseout mouseover mouseup wheel" -hx_attrs = [f'hx_{o}' for o in hx_attrs.split()] +hx_attrs = [f'hx_{o}' for o in hx_attrs.split() + hx_attrs_4only.split()] hx_attrs_annotations = { "hx_swap": Literal["innerHTML", "outerHTML", "afterbegin", "beforebegin", "beforeend", "afterend", "delete", "none"] | str, "hx_swap_oob": Literal["true", "innerHTML", "outerHTML", "afterbegin", "beforebegin", "beforeend", "afterend", "delete", "none"] | str, @@ -62,7 +65,7 @@ def __add__(self:FT, b): return f'{self}{b}' hx_attrs_annotations = {k: Optional[v] for k,v in hx_attrs_annotations.items()} hx_attrs = html_attrs + hx_attrs -hx_evt_attrs = ['hx_on__'+camel2snake(o).replace(':','_') for o in hx_evts.split()] +hx_evt_attrs = ['hx_on__'+camel2snake(o).replace(':','_') for o in hx_evts.split() + hx_evts_4only.split()] js_evt_attrs = ['hx_on_'+o for o in js_evts.split()] evt_attrs = js_evt_attrs+hx_evt_attrs diff --git a/htmxv4_migration/websocket.ipynb b/htmxv4_migration/websocket.ipynb index 25c98683..cd82232d 100644 --- a/htmxv4_migration/websocket.ipynb +++ b/htmxv4_migration/websocket.ipynb @@ -38,10 +38,14 @@ "text/html": [ "\n", "" ], @@ -82,7 +86,7 @@ ], "metadata": { "kernelspec": { - "display_name": "fasthtml (3.12.8)", + "display_name": "fasthtml_v4 (3.12.8)", "language": "python", "name": "python3" }, diff --git a/nbs/api/01_components.ipynb b/nbs/api/01_components.ipynb index f56d09fb..02ab82e4 100644 --- a/nbs/api/01_components.ipynb +++ b/nbs/api/01_components.ipynb @@ -251,10 +251,13 @@ "named = set('a button form frame iframe img input map meta object param select textarea'.split())\n", "html_attrs = 'id cls title style accesskey contenteditable dir draggable enterkeyhint hidden inert inputmode lang popover spellcheck tabindex translate'.split()\n", "hx_attrs = 'get post put delete patch trigger target swap swap_oob include select select_oob indicator push_url confirm disable replace_url vals disabled_elt ext headers history history_elt indicator inherit params preserve prompt replace_url request sync validate'\n", + "hx_attrs_4only = 'action method config ignore' # https://four.htmx.org/docs/get-started/migration#attributes\n", "\n", "hx_evts = 'abort afterOnLoad afterProcessNode afterRequest afterSettle afterSwap beforeCleanupElement beforeOnLoad beforeProcessNode beforeRequest beforeSwap beforeSend beforeTransition configRequest confirm historyCacheError historyCacheMiss historyCacheMissError historyCacheMissLoad historyRestore beforeHistorySave load noSSESourceError onLoadError oobAfterSwap oobBeforeSwap oobErrorNoTarget prompt pushedIntoHistory replacedInHistory responseError sendAbort sendError sseError sseOpen swapError targetError timeout validation:validate validation:failed validation:halted xhr:abort xhr:loadend xhr:loadstart xhr:progress'\n", + "# https://four.htmx.org/docs/get-started/migration#renamed-events\n", + "hx_evts_4only = 'after:init before:cleanup before:history:update before:init before:process before:history:restore after:history:push after:history:replace error'\n", "js_evts = \"blur change contextmenu focus input invalid reset select submit keydown keypress keyup click dblclick mousedown mouseenter mouseleave mousemove mouseout mouseover mouseup wheel\"\n", - "hx_attrs = [f'hx_{o}' for o in hx_attrs.split()]\n", + "hx_attrs = [f'hx_{o}' for o in hx_attrs.split() + hx_attrs_4only.split()]\n", "hx_attrs_annotations = {\n", " \"hx_swap\": Literal[\"innerHTML\", \"outerHTML\", \"afterbegin\", \"beforebegin\", \"beforeend\", \"afterend\", \"delete\", \"none\"] | str,\n", " \"hx_swap_oob\": Literal[\"true\", \"innerHTML\", \"outerHTML\", \"afterbegin\", \"beforebegin\", \"beforeend\", \"afterend\", \"delete\", \"none\"] | str,\n", @@ -269,7 +272,7 @@ "hx_attrs_annotations = {k: Optional[v] for k,v in hx_attrs_annotations.items()} \n", "hx_attrs = html_attrs + hx_attrs\n", "\n", - "hx_evt_attrs = ['hx_on__'+camel2snake(o).replace(':','_') for o in hx_evts.split()]\n", + "hx_evt_attrs = ['hx_on__'+camel2snake(o).replace(':','_') for o in hx_evts.split() + hx_evts_4only.split()]\n", "js_evt_attrs = ['hx_on_'+o for o in js_evts.split()]\n", "evt_attrs = js_evt_attrs+hx_evt_attrs" ] @@ -1476,7 +1479,11 @@ ] } ], - "metadata": {}, + "metadata": { + "language_info": { + "name": "python" + } + }, "nbformat": 4, "nbformat_minor": 5 } From 135729d6544e9ba0b4bebeea932dfb346fd08d9d Mon Sep 17 00:00:00 2001 From: Dien-Hoa Date: Fri, 22 May 2026 22:41:57 -0400 Subject: [PATCH 18/19] add compat checker --- htmxv4_migration/compat_checker.ipynb | 537 ++++++++++++++++++ htmxv4_migration/compat_hx_tests/README.md | 14 + .../compat_hx_tests/v2_compatible.py | 8 + .../v2_incompatible_v4_attrs.py | 9 + .../v2_incompatible_v4_events.py | 9 + .../compat_hx_tests/v4_compatible.py | 10 + .../v4_incompatible_v2_attrs.py | 14 + .../v4_incompatible_v2_events.py | 10 + 8 files changed, 611 insertions(+) create mode 100644 htmxv4_migration/compat_checker.ipynb create mode 100644 htmxv4_migration/compat_hx_tests/README.md create mode 100644 htmxv4_migration/compat_hx_tests/v2_compatible.py create mode 100644 htmxv4_migration/compat_hx_tests/v2_incompatible_v4_attrs.py create mode 100644 htmxv4_migration/compat_hx_tests/v2_incompatible_v4_events.py create mode 100644 htmxv4_migration/compat_hx_tests/v4_compatible.py create mode 100644 htmxv4_migration/compat_hx_tests/v4_incompatible_v2_attrs.py create mode 100644 htmxv4_migration/compat_hx_tests/v4_incompatible_v2_events.py diff --git a/htmxv4_migration/compat_checker.ipynb b/htmxv4_migration/compat_checker.ipynb new file mode 100644 index 00000000..2a6e264a --- /dev/null +++ b/htmxv4_migration/compat_checker.ipynb @@ -0,0 +1,537 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "#| default_exp htmx_compat" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# htmx compatibility checker\n", + "\n", + "Checker for finding obvious htmx v2/v4 compatibility issues in FastHTML source code." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "import ast\n", + "from dataclasses import dataclass\n", + "from pathlib import Path\n", + "from fastcore.script import call_parse\n", + "\n", + "HTMX_V4ONLY_ATTRS = 'action method config ignore'\n", + "HTMX_V2ONLY_ATTRS = 'disabled_elt ext history history_elt inherit params prompt request'\n", + "\n", + "HTMX_V4ONLY_EVTS = 'after:init before:cleanup before:history:update before:init before:process before:history:restore after:history:push after:history:replace error'\n", + "HTMX_V2ONLY_EVTS = 'afterOnLoad afterProcessNode beforeCleanupElement beforeHistorySave beforeOnLoad beforeProcessNode beforeTransition historyCacheMiss historyRestore load oobAfterSwap oobBeforeSwap pushedIntoHistory replacedInHistory sendError swapError targetError timeout'\n", + "\n", + "def _hx_attr_kwargs(xs): return {f'hx_{x}' for x in xs.split()}\n", + "def _hx_evt_kwarg(x): return 'hx_on__' + _camel2snake(x).replace(':', '_')\n", + "def _hx_evt_kwargs(xs): return {_hx_evt_kwarg(x) for x in xs.split()}\n", + "\n", + "def _camel2snake(s):\n", + " res = []\n", + " for i,c in enumerate(s):\n", + " if c.isupper() and i and s[i-1] not in ':_': res.append('_')\n", + " res.append(c.lower())\n", + " return ''.join(res)\n", + "\n", + "V4ONLY_KWARGS = _hx_attr_kwargs(HTMX_V4ONLY_ATTRS) | _hx_evt_kwargs(HTMX_V4ONLY_EVTS)\n", + "V2ONLY_KWARGS = _hx_attr_kwargs(HTMX_V2ONLY_ATTRS) | _hx_evt_kwargs(HTMX_V2ONLY_EVTS)" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "e9b33ae8", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'hx_action',\n", + " 'hx_config',\n", + " 'hx_ignore',\n", + " 'hx_method',\n", + " 'hx_on__after_history_push',\n", + " 'hx_on__after_history_replace',\n", + " 'hx_on__after_init',\n", + " 'hx_on__before_cleanup',\n", + " 'hx_on__before_history_restore',\n", + " 'hx_on__before_history_update',\n", + " 'hx_on__before_init',\n", + " 'hx_on__before_process',\n", + " 'hx_on__error'}" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "V4ONLY_KWARGS" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "814c5e73", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'hx_disabled_elt',\n", + " 'hx_ext',\n", + " 'hx_history',\n", + " 'hx_history_elt',\n", + " 'hx_inherit',\n", + " 'hx_on__after_on_load',\n", + " 'hx_on__after_process_node',\n", + " 'hx_on__before_cleanup_element',\n", + " 'hx_on__before_history_save',\n", + " 'hx_on__before_on_load',\n", + " 'hx_on__before_process_node',\n", + " 'hx_on__before_transition',\n", + " 'hx_on__history_cache_miss',\n", + " 'hx_on__history_restore',\n", + " 'hx_on__load',\n", + " 'hx_on__oob_after_swap',\n", + " 'hx_on__oob_before_swap',\n", + " 'hx_on__pushed_into_history',\n", + " 'hx_on__replaced_in_history',\n", + " 'hx_on__send_error',\n", + " 'hx_on__swap_error',\n", + " 'hx_on__target_error',\n", + " 'hx_on__timeout',\n", + " 'hx_params',\n", + " 'hx_prompt',\n", + " 'hx_request'}" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "V2ONLY_KWARGS" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "from fastcore.test import test_eq\n", + "\n", + "test_eq(_hx_attr_kwargs('action method'), {'hx_action', 'hx_method'})\n", + "test_eq(_hx_evt_kwarg('after:init'), 'hx_on__after_init')\n", + "test_eq(_hx_evt_kwarg('afterOnLoad'), 'hx_on__after_on_load')\n", + "assert 'hx_vals' not in V2ONLY_KWARGS\n", + "assert 'hx_vals' not in V4ONLY_KWARGS" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "@dataclass\n", + "class HtmxCompatIssue:\n", + " lineno: int\n", + " col_offset: int\n", + " name: str\n", + " target: int\n", + " message: str\n", + "\n", + "def _const_bool(node):\n", + " return node.value if isinstance(node, ast.Constant) and isinstance(node.value, bool) else None\n", + "\n", + "def _call_name(node):\n", + " if isinstance(node.func, ast.Name): return node.func.id\n", + " if isinstance(node.func, ast.Attribute): return node.func.attr\n", + " return None\n", + "\n", + "def _call_htmx4_kw(node):\n", + " for kw in node.keywords:\n", + " if kw.arg == 'htmx4': return _const_bool(kw.value)\n", + " return False" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "class HtmxCompatVisitor(ast.NodeVisitor):\n", + " def __init__(self, target=None):\n", + " self.target = target\n", + " self.inferred_versions = []\n", + " self.issues = []\n", + "\n", + " def visit_Call(self, node):\n", + " name = _call_name(node)\n", + " if name in {'fast_app', 'FastHTML'}:\n", + " htmx4 = _call_htmx4_kw(node)\n", + " if htmx4 is not None: self.inferred_versions.append(4 if htmx4 else 2)\n", + " self._check_keywords(node)\n", + " self.generic_visit(node)\n", + "\n", + " def _resolved_target(self):\n", + " if self.target in (2,4): return self.target\n", + " versions = set(self.inferred_versions)\n", + " return versions.pop() if len(versions) == 1 else None\n", + "\n", + " def _check_keywords(self, node):\n", + " target = self._resolved_target()\n", + " if target is None: return\n", + " bad = V2ONLY_KWARGS if target == 4 else V4ONLY_KWARGS\n", + " bad_version = 2 if target == 4 else 4\n", + " for kw in node.keywords:\n", + " if kw.arg is None: continue\n", + " if kw.arg in bad:\n", + " self.issues.append(HtmxCompatIssue(\n", + " kw.value.lineno,\n", + " kw.value.col_offset,\n", + " kw.arg,\n", + " target,\n", + " f'{kw.arg} is htmx v{bad_version}-only, but this source targets htmx v{target}.'))" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "def infer_htmx_version(src):\n", + " \"Infer htmx target version from direct fast_app/FastHTML calls, returning 2, 4, or None.\"\n", + " tree = ast.parse(src)\n", + " visitor = HtmxCompatVisitor()\n", + " visitor.visit(tree)\n", + " versions = set(visitor.inferred_versions)\n", + " return versions.pop() if len(versions) == 1 else None\n", + "\n", + "def check_htmx_compat(src, target=None):\n", + " \"Return static compatibility issues for FastHTML htmx kwargs in `src`.\"\n", + " tree = ast.parse(src)\n", + " visitor = HtmxCompatVisitor(target=target)\n", + " visitor.visit(tree)\n", + " return visitor.issues" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "def _py_files(path):\n", + " path = Path(path)\n", + " if path.is_file(): return [path]\n", + " return sorted(p for p in path.rglob('*.py') if '.venv' not in p.parts and '__pycache__' not in p.parts)\n", + "\n", + "def check_htmx_compat_path(path, target=None):\n", + " \"Return `(path, issue)` pairs for Python files under `path`.\"\n", + " res = []\n", + " for p in _py_files(path):\n", + " try: src = p.read_text()\n", + " except UnicodeDecodeError: continue\n", + " for issue in check_htmx_compat(src, target=target): res.append((p, issue))\n", + " return res\n", + "\n", + "@call_parse\n", + "def htmx_compat_check(\n", + " path:str='.', # File or directory to scan\n", + " target:int=0 # Target htmx version. Use 0 to infer from fast_app/FastHTML calls.\n", + "):\n", + " \"Check FastHTML source for obvious htmx v2/v4 compatibility issues.\"\n", + " issues = check_htmx_compat_path(path, target=target or None)\n", + " for p,issue in issues:\n", + " print(f'{p}:{issue.lineno}:{issue.col_offset + 1}: {issue.message}')\n", + " if issues: raise SystemExit(1)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Version inference" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [], + "source": [ + "test_eq(infer_htmx_version('app, rt = fast_app(htmx4=True)'), 4)\n", + "test_eq(infer_htmx_version('app = FastHTML(htmx4=True)'), 4)\n", + "test_eq(infer_htmx_version('app, rt = fast_app()'), 2)\n", + "test_eq(infer_htmx_version('app = FastHTML(htmx4=False)'), 2)\n", + "test_eq(infer_htmx_version('fast_app(); FastHTML(htmx4=True)'), None)\n", + "test_eq(infer_htmx_version('fast_app(htmx4=USE_V4)'), None)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## v4-only attrs in a v2 app" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [], + "source": [ + "src = '''\n", + "app, rt = fast_app()\n", + "Button('Go', hx_action='/items', hx_method='get')\n", + "Div(hx_config='timeout:1s')\n", + "Div(hx_ignore=True)\n", + "'''\n", + "issues = check_htmx_compat(src)\n", + "test_eq([o.name for o in issues], ['hx_action', 'hx_method', 'hx_config', 'hx_ignore'])\n", + "test_eq({o.target for o in issues}, {2})" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "3be658cd", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[HtmxCompatIssue(lineno=3, col_offset=23, name='hx_action', target=2, message='hx_action is htmx v4-only, but this source targets htmx v2.'),\n", + " HtmxCompatIssue(lineno=3, col_offset=43, name='hx_method', target=2, message='hx_method is htmx v4-only, but this source targets htmx v2.'),\n", + " HtmxCompatIssue(lineno=4, col_offset=14, name='hx_config', target=2, message='hx_config is htmx v4-only, but this source targets htmx v2.'),\n", + " HtmxCompatIssue(lineno=5, col_offset=14, name='hx_ignore', target=2, message='hx_ignore is htmx v4-only, but this source targets htmx v2.')]" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "issues" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## v2-only attrs in a v4 app" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [], + "source": [ + "src = '''\n", + "app, rt = fast_app(htmx4=True)\n", + "Div(hx_disabled_elt='this')\n", + "Div(hx_ext='ws')\n", + "Div(hx_history='false')\n", + "Div(hx_history_elt='#x')\n", + "Div(hx_inherit='*')\n", + "Div(hx_params='none')\n", + "Div(hx_prompt='Name?')\n", + "Div(hx_request='timeout:1s')\n", + "'''\n", + "issues = check_htmx_compat(src)\n", + "test_eq([o.name for o in issues], ['hx_disabled_elt', 'hx_ext', 'hx_history', 'hx_history_elt', 'hx_inherit', 'hx_params', 'hx_prompt', 'hx_request'])\n", + "test_eq({o.target for o in issues}, {4})" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "3aa2d7fd", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[HtmxCompatIssue(lineno=3, col_offset=20, name='hx_disabled_elt', target=4, message='hx_disabled_elt is htmx v2-only, but this source targets htmx v4.'),\n", + " HtmxCompatIssue(lineno=4, col_offset=11, name='hx_ext', target=4, message='hx_ext is htmx v2-only, but this source targets htmx v4.'),\n", + " HtmxCompatIssue(lineno=5, col_offset=15, name='hx_history', target=4, message='hx_history is htmx v2-only, but this source targets htmx v4.'),\n", + " HtmxCompatIssue(lineno=6, col_offset=19, name='hx_history_elt', target=4, message='hx_history_elt is htmx v2-only, but this source targets htmx v4.'),\n", + " HtmxCompatIssue(lineno=7, col_offset=15, name='hx_inherit', target=4, message='hx_inherit is htmx v2-only, but this source targets htmx v4.'),\n", + " HtmxCompatIssue(lineno=8, col_offset=14, name='hx_params', target=4, message='hx_params is htmx v2-only, but this source targets htmx v4.'),\n", + " HtmxCompatIssue(lineno=9, col_offset=14, name='hx_prompt', target=4, message='hx_prompt is htmx v2-only, but this source targets htmx v4.'),\n", + " HtmxCompatIssue(lineno=10, col_offset=15, name='hx_request', target=4, message='hx_request is htmx v2-only, but this source targets htmx v4.')]" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "issues" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## v4-only events in a v2 app" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [], + "source": [ + "src = '''\n", + "app, rt = fast_app()\n", + "Div(hx_on__after_init='console.log(event)')\n", + "Div(hx_on__before_history_update='console.log(event)')\n", + "Div(hx_on__error='console.log(event)')\n", + "'''\n", + "issues = check_htmx_compat(src)\n", + "test_eq([o.name for o in issues], ['hx_on__after_init', 'hx_on__before_history_update', 'hx_on__error'])" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "343cf0ce", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[HtmxCompatIssue(lineno=3, col_offset=22, name='hx_on__after_init', target=2, message='hx_on__after_init is htmx v4-only, but this source targets htmx v2.'),\n", + " HtmxCompatIssue(lineno=4, col_offset=33, name='hx_on__before_history_update', target=2, message='hx_on__before_history_update is htmx v4-only, but this source targets htmx v2.'),\n", + " HtmxCompatIssue(lineno=5, col_offset=17, name='hx_on__error', target=2, message='hx_on__error is htmx v4-only, but this source targets htmx v2.')]" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "issues" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## v2-only events in a v4 app" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [], + "source": [ + "src = '''\n", + "app, rt = fast_app(htmx4=True)\n", + "Div(hx_on__after_on_load='console.log(event)')\n", + "Div(hx_on__before_cleanup_element='console.log(event)')\n", + "Div(hx_on__history_cache_miss='console.log(event)')\n", + "Div(hx_on__send_error='console.log(event)')\n", + "'''\n", + "issues = check_htmx_compat(src)\n", + "test_eq([o.name for o in issues], ['hx_on__after_on_load', 'hx_on__before_cleanup_element', 'hx_on__history_cache_miss', 'hx_on__send_error'])" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "09fa6b91", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[HtmxCompatIssue(lineno=3, col_offset=25, name='hx_on__after_on_load', target=4, message='hx_on__after_on_load is htmx v2-only, but this source targets htmx v4.'),\n", + " HtmxCompatIssue(lineno=4, col_offset=34, name='hx_on__before_cleanup_element', target=4, message='hx_on__before_cleanup_element is htmx v2-only, but this source targets htmx v4.'),\n", + " HtmxCompatIssue(lineno=5, col_offset=30, name='hx_on__history_cache_miss', target=4, message='hx_on__history_cache_miss is htmx v2-only, but this source targets htmx v4.'),\n", + " HtmxCompatIssue(lineno=6, col_offset=22, name='hx_on__send_error', target=4, message='hx_on__send_error is htmx v2-only, but this source targets htmx v4.')]" + ] + }, + "execution_count": 18, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "issues" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Export " + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": {}, + "outputs": [], + "source": [ + "from nbdev.export import nb_export\n", + "\n", + "# Export this notebook as one standalone script next to the notebook.\n", + "# nbdev names the option `solo_nb`; this is the current equivalent of a solo export.\n", + "nb_export('compat_checker.ipynb', lib_path='.', name='htmx_compat_checker', solo_nb=True)\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "fasthtml_v4 (3.12.8)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.8" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/htmxv4_migration/compat_hx_tests/README.md b/htmxv4_migration/compat_hx_tests/README.md new file mode 100644 index 00000000..372d1d38 --- /dev/null +++ b/htmxv4_migration/compat_hx_tests/README.md @@ -0,0 +1,14 @@ +# htmx compatibility checker fixtures + +Small scripts for testing `htmx_compat_checker.py`. + +Expected behavior with inferred target: + +```bash +python ../htmx_compat.py --path v2_compatible.py +python ../htmx_compat.py --path v4_compatible.py +python ../htmx_compat.py --path v2_incompatible_v4_attrs.py +python ../htmx_compat.py --path v4_incompatible_v2_attrs.py +``` + +The compatible files should exit `0`. The incompatible files should print findings and exit `1`. diff --git a/htmxv4_migration/compat_hx_tests/v2_compatible.py b/htmxv4_migration/compat_hx_tests/v2_compatible.py new file mode 100644 index 00000000..708b0d64 --- /dev/null +++ b/htmxv4_migration/compat_hx_tests/v2_compatible.py @@ -0,0 +1,8 @@ +from fasthtml.common import * + +app, rt = fast_app() + +ok = Div( + Button("Load", hx_get="/items", hx_target="#items", hx_swap="innerHTML"), + Div(id="items", hx_vals={"page": 1}), +) diff --git a/htmxv4_migration/compat_hx_tests/v2_incompatible_v4_attrs.py b/htmxv4_migration/compat_hx_tests/v2_incompatible_v4_attrs.py new file mode 100644 index 00000000..f3ea2d06 --- /dev/null +++ b/htmxv4_migration/compat_hx_tests/v2_incompatible_v4_attrs.py @@ -0,0 +1,9 @@ +from fasthtml.common import * + +app, rt = fast_app() + +bad = Div( + Button("Load", hx_action="/items", hx_method="get"), + Div(hx_config="timeout:1s"), + Div(hx_ignore=True), +) diff --git a/htmxv4_migration/compat_hx_tests/v2_incompatible_v4_events.py b/htmxv4_migration/compat_hx_tests/v2_incompatible_v4_events.py new file mode 100644 index 00000000..7222ef3a --- /dev/null +++ b/htmxv4_migration/compat_hx_tests/v2_incompatible_v4_events.py @@ -0,0 +1,9 @@ +from fasthtml.common import * + +app, rt = fast_app() + +bad = Div( + hx_on__after_init="console.log(event)", + hx_on__before_history_update="console.log(event)", + hx_on__error="console.log(event)", +) diff --git a/htmxv4_migration/compat_hx_tests/v4_compatible.py b/htmxv4_migration/compat_hx_tests/v4_compatible.py new file mode 100644 index 00000000..db6492b1 --- /dev/null +++ b/htmxv4_migration/compat_hx_tests/v4_compatible.py @@ -0,0 +1,10 @@ +from fasthtml.common import * + +app, rt = fast_app(htmx=False, htmx4=True) + +ok = Div( + Button("Load", hx_action="/items", hx_method="get", hx_target="#items"), + Div(id="items", hx_config="timeout:1s"), + Div(hx_ignore=True), + Div(hx_on__after_init="console.log(event)"), +) diff --git a/htmxv4_migration/compat_hx_tests/v4_incompatible_v2_attrs.py b/htmxv4_migration/compat_hx_tests/v4_incompatible_v2_attrs.py new file mode 100644 index 00000000..40b8306e --- /dev/null +++ b/htmxv4_migration/compat_hx_tests/v4_incompatible_v2_attrs.py @@ -0,0 +1,14 @@ +from fasthtml.common import * + +app, rt = fast_app(htmx=False, htmx4=True) + +bad = Div( + Div(hx_disabled_elt="this"), + Div(hx_ext="ws"), + Div(hx_history="false"), + Div(hx_history_elt="#history"), + Div(hx_inherit="*"), + Div(hx_params="none"), + Div(hx_prompt="Name?"), + Div(hx_request="timeout:1s"), +) diff --git a/htmxv4_migration/compat_hx_tests/v4_incompatible_v2_events.py b/htmxv4_migration/compat_hx_tests/v4_incompatible_v2_events.py new file mode 100644 index 00000000..3df0df60 --- /dev/null +++ b/htmxv4_migration/compat_hx_tests/v4_incompatible_v2_events.py @@ -0,0 +1,10 @@ +from fasthtml.common import * + +app, rt = fast_app(htmx=False, htmx4=True) + +bad = Div( + hx_on__after_on_load="console.log(event)", + hx_on__before_cleanup_element="console.log(event)", + hx_on__history_cache_miss="console.log(event)", + hx_on__send_error="console.log(event)", +) From ad7d63101ddf6d048bed27092e390cbbce8715ca Mon Sep 17 00:00:00 2001 From: Dien-Hoa Date: Sun, 24 May 2026 19:48:02 -0400 Subject: [PATCH 19/19] add checker script --- htmxv4_migration/htmx_compat_checker.py | 121 ++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 htmxv4_migration/htmx_compat_checker.py diff --git a/htmxv4_migration/htmx_compat_checker.py b/htmxv4_migration/htmx_compat_checker.py new file mode 100644 index 00000000..42d3542c --- /dev/null +++ b/htmxv4_migration/htmx_compat_checker.py @@ -0,0 +1,121 @@ +# AUTOGENERATED! DO NOT EDIT! File to edit: compat_checker.ipynb. + +import ast +from dataclasses import dataclass +from pathlib import Path +from fastcore.script import call_parse + +HTMX_V4ONLY_ATTRS = 'action method config ignore' +HTMX_V2ONLY_ATTRS = 'disabled_elt ext history history_elt inherit params prompt request' + +HTMX_V4ONLY_EVTS = 'after:init before:cleanup before:history:update before:init before:process before:history:restore after:history:push after:history:replace error' +HTMX_V2ONLY_EVTS = 'afterOnLoad afterProcessNode beforeCleanupElement beforeHistorySave beforeOnLoad beforeProcessNode beforeTransition historyCacheMiss historyRestore load oobAfterSwap oobBeforeSwap pushedIntoHistory replacedInHistory sendError swapError targetError timeout' + +def _hx_attr_kwargs(xs): return {f'hx_{x}' for x in xs.split()} +def _hx_evt_kwarg(x): return 'hx_on__' + _camel2snake(x).replace(':', '_') +def _hx_evt_kwargs(xs): return {_hx_evt_kwarg(x) for x in xs.split()} + +def _camel2snake(s): + res = [] + for i,c in enumerate(s): + if c.isupper() and i and s[i-1] not in ':_': res.append('_') + res.append(c.lower()) + return ''.join(res) + +V4ONLY_KWARGS = _hx_attr_kwargs(HTMX_V4ONLY_ATTRS) | _hx_evt_kwargs(HTMX_V4ONLY_EVTS) +V2ONLY_KWARGS = _hx_attr_kwargs(HTMX_V2ONLY_ATTRS) | _hx_evt_kwargs(HTMX_V2ONLY_EVTS) + +@dataclass +class HtmxCompatIssue: + lineno: int + col_offset: int + name: str + target: int + message: str + +def _const_bool(node): + return node.value if isinstance(node, ast.Constant) and isinstance(node.value, bool) else None + +def _call_name(node): + if isinstance(node.func, ast.Name): return node.func.id + if isinstance(node.func, ast.Attribute): return node.func.attr + return None + +def _call_htmx4_kw(node): + for kw in node.keywords: + if kw.arg == 'htmx4': return _const_bool(kw.value) + return False + +class HtmxCompatVisitor(ast.NodeVisitor): + def __init__(self, target=None): + self.target = target + self.inferred_versions = [] + self.issues = [] + + def visit_Call(self, node): + name = _call_name(node) + if name in {'fast_app', 'FastHTML'}: + htmx4 = _call_htmx4_kw(node) + if htmx4 is not None: self.inferred_versions.append(4 if htmx4 else 2) + self._check_keywords(node) + self.generic_visit(node) + + def _resolved_target(self): + if self.target in (2,4): return self.target + versions = set(self.inferred_versions) + return versions.pop() if len(versions) == 1 else None + + def _check_keywords(self, node): + target = self._resolved_target() + if target is None: return + bad = V2ONLY_KWARGS if target == 4 else V4ONLY_KWARGS + bad_version = 2 if target == 4 else 4 + for kw in node.keywords: + if kw.arg is None: continue + if kw.arg in bad: + self.issues.append(HtmxCompatIssue( + kw.value.lineno, + kw.value.col_offset, + kw.arg, + target, + f'{kw.arg} is htmx v{bad_version}-only, but this source targets htmx v{target}.')) + +def infer_htmx_version(src): + "Infer htmx target version from direct fast_app/FastHTML calls, returning 2, 4, or None." + tree = ast.parse(src) + visitor = HtmxCompatVisitor() + visitor.visit(tree) + versions = set(visitor.inferred_versions) + return versions.pop() if len(versions) == 1 else None + +def check_htmx_compat(src, target=None): + "Return static compatibility issues for FastHTML htmx kwargs in `src`." + tree = ast.parse(src) + visitor = HtmxCompatVisitor(target=target) + visitor.visit(tree) + return visitor.issues + +def _py_files(path): + path = Path(path) + if path.is_file(): return [path] + return sorted(p for p in path.rglob('*.py') if '.venv' not in p.parts and '__pycache__' not in p.parts) + +def check_htmx_compat_path(path, target=None): + "Return `(path, issue)` pairs for Python files under `path`." + res = [] + for p in _py_files(path): + try: src = p.read_text() + except UnicodeDecodeError: continue + for issue in check_htmx_compat(src, target=target): res.append((p, issue)) + return res + +@call_parse +def htmx_compat_check( + path:str='.', # File or directory to scan + target:int=0 # Target htmx version. Use 0 to infer from fast_app/FastHTML calls. +): + "Check FastHTML source for obvious htmx v2/v4 compatibility issues." + issues = check_htmx_compat_path(path, target=target or None) + for p,issue in issues: + print(f'{p}:{issue.lineno}:{issue.col_offset + 1}: {issue.message}') + if issues: raise SystemExit(1)