diff --git a/fasthtml/components.py b/fasthtml/components.py
index d952dce8..1470dfa4 100644
--- a/fasthtml/components.py
+++ b/fasthtml/components.py
@@ -5,18 +5,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', '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
@@ -46,10 +46,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,
@@ -64,7 +67,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
@@ -117,6 +120,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 +251,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 3b3ecfa3..e39e4822 100644
--- a/fasthtml/core.py
+++ b/fasthtml/core.py
@@ -5,13 +5,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',
- 'add_sig_param', 'into', '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', 'add_sig_param', 'into', 'MiddlewareBase', 'FtResponse', 'unqid']
# %% ../nbs/api/00_core.ipynb #23503b9e
import json,uuid,inspect,types,asyncio,inspect,random,contextlib,itsdangerous
@@ -62,6 +62,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")
@@ -69,7 +71,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):
@@ -236,6 +239,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}")
@@ -277,7 +281,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() if v is not None})
+ hdrs = Headers({k.lower():v for k,v in (data.pop('HEADERS', {}) or data.pop('headers', {})).items() if v is not None})
return await _find_ps(ws, data, hdrs, params)
# %% ../nbs/api/00_core.ipynb #dcc15129
@@ -498,11 +502,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",
- "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-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-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-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")
@@ -547,11 +555,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: 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
+ elif htmx:
+ hdrs = [htmxsrc,fhjsscr] + hdrs
return [charset, viewport] + hdrs
# %% ../nbs/api/00_core.ipynb #2c5285ae
@@ -562,11 +576,24 @@ def def_hdrs(htmx=True, 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:
@@ -599,19 +626,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=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):
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)
@@ -621,8 +651,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 4feb59b8..74a718b8 100644
--- a/fasthtml/fastapp.py
+++ b/fasthtml/fastapp.py
@@ -44,6 +44,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
@@ -70,7 +71,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/jupyter.py b/fasthtml/jupyter.py
index e59a6b32..cf7a1ea0 100644
--- a/fasthtml/jupyter.py
+++ b/fasthtml/jupyter.py
@@ -71,15 +71,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 #79406618
class JupyUvi:
@@ -91,8 +98,8 @@ def __init__(self, app, log_level="error", host='0.0.0.0', port=8000, start=True
self._live_ver = 0
if live: self._setup_live(app)
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,daemon=self.daemon, **self.kwargs)
diff --git a/fasthtml/pico.py b/fasthtml/pico.py
index c2a63834..f57aca76 100644
--- a/fasthtml/pico.py
+++ b/fasthtml/pico.py
@@ -86,7 +86,10 @@ 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 #b8c98614
+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))
diff --git a/fasthtml/xtend.py b/fasthtml/xtend.py
index c88eb96c..283ffbb0 100644
--- a/fasthtml/xtend.py
+++ b/fasthtml/xtend.py
@@ -166,10 +166,11 @@ 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, htmx4=False, metaChar=None):
+ if metaChar is None: metaChar = '-' if htmx4 else ':'
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:
@@ -242,23 +243,24 @@ 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;
+}
+
+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${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
def with_sid(app, dest, path='/'):
diff --git a/htmxv4_migration/attribute_inheritance.ipynb b/htmxv4_migration/attribute_inheritance.ipynb
new file mode 100644
index 00000000..91034e08
--- /dev/null
+++ b/htmxv4_migration/attribute_inheritance.ipynb
@@ -0,0 +1,108 @@
+{
+ "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": 1,
+ "id": "6941a0f0",
+ "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_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/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)",
+)
diff --git a/htmxv4_migration/event_structure.ipynb b/htmxv4_migration/event_structure.ipynb
new file mode 100644
index 00000000..0007dc44
--- /dev/null
+++ b/htmxv4_migration/event_structure.ipynb
@@ -0,0 +1,122 @@
+{
+ "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 *\n",
+ "from fasthtml.pico import PicoBusy"
+ ]
+ },
+ {
+ "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)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "04a38604",
+ "metadata": {},
+ "outputs": [],
+ "source": []
+ }
+ ],
+ "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/htmxv4_migration/fasthtml_htmxv4_migration.md b/htmxv4_migration/fasthtml_htmxv4_migration.md
new file mode 100644
index 00000000..ac1c9637
--- /dev/null
+++ b/htmxv4_migration/fasthtml_htmxv4_migration.md
@@ -0,0 +1,246 @@
+# FastHTML Upgrade from htmx2 to htmx4
+
+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`. Here are the 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 (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:
+
+| Layer | Example |
+|-------|---------|
+| htmx v4 default | `htmx:before:request` |
+| With `metaCharacter="-"` | `htmx-before-request` |
+| FastHTML Python (`_` → `-`) | `htmx_before_request` |
+
+This is configured automatically when you set `htmx4=True`.
+
+### Renamed Events
+
+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)
+
+| 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` (``) 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
+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`, 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
+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)* | The extension auto-registers when the 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 WebSocket support also includes a JSON envelope format for server-to-client messages, 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/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)
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/htmxv4_migration/partial.ipynb b/htmxv4_migration/partial.ipynb
new file mode 100644
index 00000000..4f483768
--- /dev/null
+++ b/htmxv4_migration/partial.ipynb
@@ -0,0 +1,148 @@
+{
+ "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 *\n",
+ "from fasthtml.pico import Grid\n"
+ ]
+ },
+ {
+ "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)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "1852a4f4",
+ "metadata": {},
+ "outputs": [],
+ "source": []
+ }
+ ],
+ "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/htmxv4_migration/request_headers.ipynb b/htmxv4_migration/request_headers.ipynb
new file mode 100644
index 00000000..cdd1f85e
--- /dev/null
+++ b/htmxv4_migration/request_headers.ipynb
@@ -0,0 +1,114 @@
+{
+ "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)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "c949d496",
+ "metadata": {},
+ "outputs": [],
+ "source": []
+ }
+ ],
+ "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/htmxv4_migration/sse.ipynb b/htmxv4_migration/sse.ipynb
new file mode 100644
index 00000000..da2308ec
--- /dev/null
+++ b/htmxv4_migration/sse.ipynb
@@ -0,0 +1,155 @@
+{
+ "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)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "ae6b90aa",
+ "metadata": {},
+ "outputs": [],
+ "source": []
+ }
+ ],
+ "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/htmxv4_migration/websocket.ipynb b/htmxv4_migration/websocket.ipynb
new file mode 100644
index 00000000..cd82232d
--- /dev/null
+++ b/htmxv4_migration/websocket.ipynb
@@ -0,0 +1,110 @@
+{
+ "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_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 8cddd761..0482199e 100644
--- a/nbs/api/00_core.ipynb
+++ b/nbs/api/00_core.ipynb
@@ -132,7 +132,7 @@
{
"data": {
"text/plain": [
- "datetime.datetime(2026, 5, 4, 14, 0)"
+ "datetime.datetime(2026, 3, 30, 14, 0)"
]
},
"execution_count": null,
@@ -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,7 +261,7 @@
{
"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": null,
@@ -803,6 +806,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",
@@ -1123,7 +1127,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() if v is not None})\n",
+ " hdrs = Headers({k.lower():v for k,v in (data.pop('HEADERS', {}) or data.pop('headers', {})).items() if v is not None})\n",
" return await _find_ps(ws, data, hdrs, params)"
]
},
@@ -1560,7 +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",
- " \"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-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-beta4/dist/ext/hx-sse.js\",\n",
+ " \"live\": \"https://unpkg.com/htmx.org@4.0.0-beta4/dist/ext/hx-live.js\"\n",
"}"
]
},
@@ -1599,6 +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-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",
@@ -1732,11 +1740,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: 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",
+ " elif htmx:\n",
+ " hdrs = [htmxsrc,fhjsscr] + hdrs\n",
" return [charset, viewport] + hdrs"
]
},
@@ -1755,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"
]
},
{
@@ -1808,19 +1834,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=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",
" 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",
@@ -1830,8 +1859,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",
@@ -4699,13 +4728,10 @@
}
],
"metadata": {
- "solveit": {
- "default_code": false,
- "mode": "learning",
- "use_fence": false,
- "use_thinking": false,
- "use_tools": true,
- "ver": 2
+ "kernelspec": {
+ "display_name": "python3",
+ "language": "python",
+ "name": "python3"
}
},
"nbformat": 4,
diff --git a/nbs/api/01_components.ipynb b/nbs/api/01_components.ipynb
index 449b6a58..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"
]
@@ -547,6 +550,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",
@@ -1348,9 +1362,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'"
]
},
@@ -1464,13 +1480,8 @@
}
],
"metadata": {
- "solveit": {
- "default_code": false,
- "mode": "learning",
- "use_fence": false,
- "use_thinking": true,
- "use_tools": true,
- "ver": 2
+ "language_info": {
+ "name": "python"
}
},
"nbformat": 4,
diff --git a/nbs/api/02_xtend.ipynb b/nbs/api/02_xtend.ipynb
index 532fde05..52ae8ed6 100644
--- a/nbs/api/02_xtend.ipynb
+++ b/nbs/api/02_xtend.ipynb
@@ -460,10 +460,11 @@
"outputs": [],
"source": [
"#| export\n",
- "def HtmxOn(eventname:str, code:str):\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\", function(event) { %s })\n",
- "})''' % (eventname, code))"
+ "document.body.addEventListener(\"htmx%s%s\", function(event) { %s })\n",
+ "})''' % (metaChar, eventname, code))"
]
},
{
@@ -651,27 +652,28 @@
{
"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",
+ "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${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",
+ "\"\"\")"
]
},
{
@@ -1042,16 +1044,7 @@
"source": []
}
],
- "metadata": {
- "solveit": {
- "default_code": false,
- "mode": "learning",
- "use_fence": false,
- "use_thinking": true,
- "use_tools": true,
- "ver": 2
- }
- },
+ "metadata": {},
"nbformat": 4,
"nbformat_minor": 5
}
diff --git a/nbs/api/04_pico.ipynb b/nbs/api/04_pico.ipynb
index 32e05cda..49e5a7b3 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,14 +364,17 @@
{
"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')\"))"
+ "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' )\", htmx4=htmx4),\n",
+ " HtmxOn(evt[1], f\"{elt}.setAttribute('aria-busy', 'false')\", htmx4=htmx4))"
]
},
{
@@ -417,16 +405,7 @@
"source": []
}
],
- "metadata": {
- "solveit": {
- "default_code": false,
- "mode": "learning",
- "use_fence": false,
- "use_thinking": true,
- "use_tools": true,
- "ver": 2
- }
- },
+ "metadata": {},
"nbformat": 4,
"nbformat_minor": 5
}
diff --git a/nbs/api/06_jupyter.ipynb b/nbs/api/06_jupyter.ipynb
index c24696e1..aa84c9fb 100644
--- a/nbs/api/06_jupyter.ipynb
+++ b/nbs/api/06_jupyter.ipynb
@@ -173,15 +173,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"
]
},
{
@@ -201,8 +207,8 @@
" self._live_ver = 0\n",
" if live: self._setup_live(app)\n",
" if start: self.start()\n",
- " if not os.environ.get('IN_SOLVEIT'): htmx_config_port(port)\n",
- " \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,daemon=self.daemon, **self.kwargs)\n",
"\n",
@@ -274,27 +280,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",
@@ -351,27 +337,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",
@@ -438,27 +404,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()"
@@ -568,27 +514,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)"
]