Skip to content

Commit a7aa7a7

Browse files
committed
fix: Correctly update <body> tag attributes and content in client-side JS to avoid DOM exceptions, add a new UI test, and minify client-side JavaScript.
1 parent 172b4ee commit a7aa7a7

5 files changed

Lines changed: 136 additions & 7 deletions

File tree

htag/client_js.py

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
CLIENT_JS = """
1+
def __minify_js(js_code: str) -> str:
2+
import re
3+
# Remove single line comments (but not URL schemes like http://)
4+
js = re.sub(r'(?<!:)//.*', '', js_code)
5+
# Remove newlines and tabs
6+
js = re.sub(r'\s+', ' ', js).strip()
7+
return js
8+
9+
CLIENT_JS = __minify_js("""
210
// The client-side bridge that connects the browser to the Python server.
311
var ws;
412
var use_fallback = false;
@@ -158,7 +166,30 @@ class HtagError extends HTMLElement {
158166
// Apply partial DOM updates received from the server
159167
for(var id in data.updates) {
160168
var el = document.getElementById(id) || document.querySelector('[data-htag-id="' + id + '"]');
161-
if(el) el.outerHTML = data.updates[id];
169+
if(el) {
170+
if(el.tagName === 'BODY') {
171+
var doc = new DOMParser().parseFromString(data.updates[id], 'text/html');
172+
173+
// Sync attributes properly
174+
var newAttrNames = new Set();
175+
for(var i = 0; i < doc.body.attributes.length; i++) {
176+
var attr = doc.body.attributes[i];
177+
el.setAttribute(attr.name, attr.value);
178+
newAttrNames.add(attr.name);
179+
}
180+
// Remove old attributes that are not in the new body
181+
for(var i = el.attributes.length - 1; i >= 0; i--) {
182+
var attrName = el.attributes[i].name;
183+
if(!newAttrNames.has(attrName)) {
184+
el.removeAttribute(attrName);
185+
}
186+
}
187+
188+
el.innerHTML = doc.body.innerHTML;
189+
} else {
190+
el.outerHTML = data.updates[id];
191+
}
192+
}
162193
}
163194
164195
// Ensure overlays are still in the DOM (in case the body was replaced)
@@ -349,4 +380,5 @@ class HtagError extends HTMLElement {
349380
window.htag_transport(payload);
350381
});
351382
}
352-
"""
383+
""")
384+

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ include-package-data = true
99

1010
[project]
1111
name = "htag"
12-
version = "2.0.6"
12+
version = "2.0.7"
1313
description = "Python3 GUI toolkit for building 'beautiful' applications for mobile, web, and desktop from a single codebase"
1414
readme = "README.md"
1515
requires-python = ">=3.10"

simple.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
class Box(Tag.div):
55
styles=".box {border:1px solid green;padding:8px}"
66
def init(self):
7-
self._class = "box"
7+
self["class"] = "box"
88

99
class Showcase(Tag.App):
1010
styles='''html,body {margin:0px;padding:0px}''' # scoped

tests/ui/test_body_update.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import pytest
2+
import os
3+
import sys
4+
import multiprocessing
5+
import time
6+
import socket
7+
import re
8+
9+
from playwright.sync_api import Page, expect
10+
11+
# Ensure root directory is in sys.path
12+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')))
13+
14+
def get_free_port():
15+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
16+
s.bind(('127.0.0.1', 0))
17+
port = s.getsockname()[1]
18+
s.close()
19+
return port
20+
21+
def run_server(port):
22+
from htag import Tag
23+
from htag.server import WebApp
24+
25+
class App(Tag.App):
26+
def init(self):
27+
self.call = 0
28+
self.btn = Tag.button("Update Body", _onclick=self.update_body)
29+
self += self.btn
30+
self["class"] = "initial-class"
31+
self["data-test"] = "foo"
32+
33+
def update_body(self, ev):
34+
self.call += 1
35+
self["class"] = f"updated-class-{self.call}"
36+
self["data-test"] = "bar"
37+
# Remove an old attribute and add a new one, to test attribute syncing
38+
del self["data-test"]
39+
self["data-new"] = "baz"
40+
self += Tag.div(f"Call {self.call}", _class="result-div")
41+
42+
app = WebApp(App)
43+
app.run(port=port, open_browser=False)
44+
45+
@pytest.fixture(scope="module")
46+
def app_port():
47+
port = get_free_port()
48+
p = multiprocessing.Process(target=run_server, args=(port,))
49+
p.start()
50+
51+
# Wait for server to start
52+
time.sleep(3)
53+
54+
yield port
55+
56+
p.terminate()
57+
p.join()
58+
59+
def test_body_update_no_dom_exception(app_port, page: Page):
60+
"""
61+
This test verifies that updating the <body> tag triggers the
62+
DOMParser fallback in client_js.py and doesn't throw a DOMException
63+
when reassigning outerHTML on document.body.
64+
"""
65+
url = f"http://127.0.0.1:{app_port}"
66+
page.goto(url)
67+
68+
# Trap page errors to explicitly fail if DOMException occurs
69+
errors = []
70+
page.on("pageerror", lambda err: errors.append(err))
71+
72+
# Check initial state
73+
body = page.locator("body")
74+
expect(body).to_have_class(re.compile(r".*initial-class.*"))
75+
76+
# Click to update body
77+
page.click("button:has-text('Update Body')")
78+
79+
# Verification: Did the innerHTML update?
80+
expect(page.locator("text=Call 1")).to_be_visible()
81+
82+
# Verification: Did the classes update?
83+
expect(body).to_have_class(re.compile(r".*updated-class-1.*"))
84+
85+
# Verification: Attributes synced properly?
86+
# Playwright locator.get_attribute
87+
assert body.get_attribute("data-new") == "baz"
88+
assert body.get_attribute("data-test") is None
89+
90+
# Ensure no unhandled JS exceptions occurred!
91+
assert len(errors) == 0, f"Javascript errors found: {errors}"
92+
93+
# Click again to ensure multiple updates work smoothly
94+
page.click("button:has-text('Update Body')")
95+
expect(page.locator("text=Call 2")).to_be_visible()
96+
expect(body).to_have_class(re.compile(r".*updated-class-2.*"))
97+
assert len(errors) == 0

uv.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)