Skip to content

Commit 87f54dc

Browse files
committed
Add DOM binding dispatch
1 parent 65effa0 commit 87f54dc

5 files changed

Lines changed: 102 additions & 16 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,11 @@ URL for navigation, and is empty for connected or disconnected events.
102102
Navigation attempts are intercepted while an event handler is installed; call
103103
`Event.client.navigate` from the handler to continue them.
104104

105+
`Window.bind("button", ...)` also dispatches clicks from elements with
106+
`id="button"`, including elements added after the bridge loads. DOM click
107+
handlers receive no arguments and their replies are ignored; explicit
108+
`webui.call("button", ...)` remains available.
109+
105110
Use `Client.run` or `Window.run` when JavaScript results and errors are not
106111
needed. These methods use the protocol's `JS_QUICK` command and do not consume
107112
pending evaluation slots.

docs/PURE_ZIG_REFACTOR.md

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,6 @@ implementations.
295295

296296
| Upstream API | Current gap |
297297
|---|---|
298-
| `webui_bind()` | `Window.bind` supports explicit `webui.call()` calls, but the bridge does not automatically dispatch DOM events from an element with the same ID to that binding. |
299298
| `webui_show()`, `webui_set_root_folder()`, `webui_set_file_handler()`, `webui_set_file_handler_window()` | Content and resource handling can only be selected when creating a window; replacing them at runtime is not implemented. |
300299
| `webui_show_client()` | `Client` cannot replace the content of only one connected browser. |
301300
| `webui_is_shown()` | There is no window-level connected/shown query. |
@@ -347,6 +346,7 @@ not implementation gaps:
347346
| `webui_wait()`, `webui_wait_async()` | `Running.wait()` used directly or through `std.Io` concurrency. |
348347
| `webui_close()`, `webui_destroy()`, `webui_exit()`, `webui_clean()` | `Window.close()`, `Running.stop()`, and `App.deinit()`. |
349348
| `webui_set_context()`, `webui_get_context()` | Binding and event-handler `user_data`. |
349+
| `webui_bind()` | `Window.bind()` handles explicit `webui.call()` requests and zero-argument DOM clicks from elements with a matching ID. |
350350
| `webui_get_count()`, `webui_get_size()`, `webui_get_size_at()` | `Call.arguments.len` and `Call.bytes(index).len`. |
351351
| `webui_get_string()`, `webui_get_string_at()`, `webui_get_int()`, `webui_get_int_at()`, `webui_get_float()`, `webui_get_float_at()`, `webui_get_bool()`, `webui_get_bool_at()` | `Call.string()`, `Call.int()`, `Call.float()`, and `Call.boolean()`. |
352352
| `webui_return_string()`, `webui_return_int()`, `webui_return_float()`, `webui_return_bool()` | `Call.reply()`, `Call.replyInt()`, `Call.replyFloat()`, and `Call.replyBool()`. |
@@ -384,8 +384,6 @@ This completes the behavior represented by `webui_set_public()`,
384384

385385
### Calls, bindings, and browser bridge
386386

387-
- Make element-name bindings dispatch the same binding for DOM events while
388-
preserving explicit `webui.call()` support.
389387
- Implement bridge `setLogging()`, `encode()`, `decode()`,
390388
`setEventCallback()`, `event`, `isHighContrast()`, and
391389
`allowNavigation()`.
@@ -505,5 +503,4 @@ zig build -Dtarget=aarch64-macos
505503

506504
Continue capability parity:
507505

508-
1. Add element-name DOM binding dispatch.
509-
2. Complete the public browser bridge API.
506+
1. Complete the public browser bridge API.

src/app.zig

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1368,6 +1368,10 @@ fn onRequest(
13681368
"globalThis.__zigWebuiEvents={};\n",
13691369
.{window.event_binding != null},
13701370
) catch return failResponse(response);
1371+
response.print(
1372+
"globalThis.__zigWebuiDomBindings={};\n",
1373+
.{window.bindings.items.len != 0},
1374+
) catch return failResponse(response);
13711375
response.write(bridge) catch return failResponse(response);
13721376
return .respond;
13731377
}
@@ -1537,6 +1541,17 @@ fn onMessage(
15371541
connection.wsClose(.protocol_error, "");
15381542
return;
15391543
};
1544+
if (packet.header.command == .click) {
1545+
if (window.binding(data)) |binding| {
1546+
var call: Call = .{
1547+
.gpa = app.gpa,
1548+
.client = client,
1549+
.arguments = &.{},
1550+
};
1551+
defer call.deinit();
1552+
binding.handler(&call, binding.user_data) catch {};
1553+
}
1554+
}
15401555
window.dispatch(.{
15411556
.kind = if (packet.header.command == .click)
15421557
.click
@@ -1758,6 +1773,16 @@ fn integrationHandler(call: *Call, user_data: ?*anyopaque) !void {
17581773
try call.reply("Hello from Zig");
17591774
}
17601775

1776+
fn integrationDomBindingHandler(
1777+
call: *Call,
1778+
user_data: ?*anyopaque,
1779+
) !void {
1780+
if (call.arguments.len != 0) return error.UnexpectedArgument;
1781+
const called: *std.atomic.Value(bool) =
1782+
@ptrCast(@alignCast(user_data.?));
1783+
called.store(true, .release);
1784+
}
1785+
17611786
const IntegrationEventState = struct {
17621787
expected_click: []const u8,
17631788
connected: std.atomic.Value(bool) = .init(false),
@@ -2118,6 +2143,12 @@ test "JavaScript and Zig calls complete over HTTP and WebSocket" {
21182143
second_window.onEvent(integrationEventHandler, &secondary_events);
21192144
var called_client_id: std.atomic.Value(u64) = .init(0);
21202145
try window.bind("greet", integrationHandler, &called_client_id);
2146+
var dom_binding_called: std.atomic.Value(bool) = .init(false);
2147+
try window.bind(
2148+
"primary",
2149+
integrationDomBindingHandler,
2150+
&dom_binding_called,
2151+
);
21212152
var running = try app.start(io);
21222153
defer running.stop() catch {};
21232154
try std.testing.expect(!std.mem.eql(
@@ -2151,16 +2182,18 @@ test "JavaScript and Zig calls complete over HTTP and WebSocket" {
21512182
var target: [capability_len + 10]u8 = undefined;
21522183
var response: [8192]u8 = undefined;
21532184
const enabled = "globalThis.__zigWebuiEvents=true;";
2185+
const dom_bindings = "globalThis.__zigWebuiDomBindings=true;";
21542186
const bytes = try getTestPath(
21552187
running.inner.address,
21562188
io,
21572189
try std.fmt.bufPrint(&target, "/{s}/webui.js", .{
21582190
window.state.capability,
21592191
}),
2160-
enabled,
2192+
dom_bindings,
21612193
&response,
21622194
);
21632195
try std.testing.expect(std.mem.indexOf(u8, bytes, enabled) != null);
2196+
try std.testing.expect(std.mem.indexOf(u8, bytes, dom_bindings) != null);
21642197
}
21652198
{
21662199
var target: [capability_len + 2]u8 = undefined;
@@ -2259,16 +2292,18 @@ test "JavaScript and Zig calls complete over HTTP and WebSocket" {
22592292
var target: [capability_len + 10]u8 = undefined;
22602293
var response: [8192]u8 = undefined;
22612294
const disabled = "globalThis.__zigWebuiEvents=false;";
2295+
const dom_bindings = "globalThis.__zigWebuiDomBindings=false;";
22622296
const bytes = try getTestPath(
22632297
running.inner.address,
22642298
io,
22652299
try std.fmt.bufPrint(&target, "/{s}/webui.js", .{
22662300
external_window.state.capability,
22672301
}),
2268-
disabled,
2302+
dom_bindings,
22692303
&response,
22702304
);
22712305
try std.testing.expect(std.mem.indexOf(u8, bytes, disabled) != null);
2306+
try std.testing.expect(std.mem.indexOf(u8, bytes, dom_bindings) != null);
22722307
}
22732308

22742309
try std.testing.expectError(
@@ -2373,6 +2408,7 @@ test "JavaScript and Zig calls complete over HTTP and WebSocket" {
23732408
try std.testing.expect(primary_events.connected.load(.acquire));
23742409
try std.testing.expect(primary_events.clicked.load(.acquire));
23752410
try std.testing.expect(primary_events.navigated.load(.acquire));
2411+
try std.testing.expect(dom_binding_called.load(.acquire));
23762412
try std.testing.expect(!secondary_events.connected.load(.acquire));
23772413
const targeted_client: Client = .{
23782414
.state = window.state,

src/bridge.js

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,26 +39,28 @@
3939
socket.send(packet(command, 0, encoder.encode(value)));
4040
}
4141

42-
if (globalThis.__zigWebuiEvents) {
42+
// ponytail: Zig filters IDs to avoid injecting names; send a filtered
43+
// list only if pages with many unrelated IDs make click traffic matter.
44+
if (globalThis.__zigWebuiEvents || globalThis.__zigWebuiDomBindings) {
4345
document.addEventListener("click", (event) => {
4446
const element = event.target?.closest?.("[id]");
4547
if (element) sendEvent(commandClick, element.id);
4648

47-
if (!("navigation" in globalThis)) {
49+
if (globalThis.__zigWebuiEvents && !("navigation" in globalThis)) {
4850
const link = event.target?.closest?.("a[href]");
4951
if (link && connected) {
5052
event.preventDefault();
5153
sendEvent(commandNavigation, link.href);
5254
}
5355
}
5456
});
55-
if ("navigation" in globalThis) {
56-
globalThis.navigation.addEventListener("navigate", (event) => {
57-
if (!connected) return;
58-
if (event.cancelable) event.preventDefault();
59-
sendEvent(commandNavigation, event.destination.url);
60-
});
61-
}
57+
}
58+
if (globalThis.__zigWebuiEvents && "navigation" in globalThis) {
59+
globalThis.navigation.addEventListener("navigate", (event) => {
60+
if (!connected) return;
61+
if (event.cancelable) event.preventDefault();
62+
sendEvent(commandNavigation, event.destination.url);
63+
});
6264
}
6365

6466
socket.onopen = () => socket.send(

src/bridge.test.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ test("bridge handles commands and external script origins", async () => {
3838
globalThis.WebSocket = WebSocketMock;
3939
globalThis.__zigWebuiCapability = "0123456789abcdef0123456789abcdef";
4040
globalThis.__zigWebuiEvents = true;
41+
globalThis.__zigWebuiDomBindings = false;
4142
globalThis.__zigWebuiToken = 7;
4243
globalThis.document = {
4344
addEventListener(type, listener) {
@@ -168,6 +169,50 @@ test("bridge handles commands and external script origins", async () => {
168169
new TextDecoder().decode(eventPacket.subarray(8)),
169170
"http://localhost/history",
170171
);
172+
173+
delete globalThis.navigation;
174+
globalThis.__zigWebuiEvents = false;
175+
globalThis.__zigWebuiDomBindings = true;
176+
clickListener = undefined;
177+
delete globalThis.webui;
178+
delete require.cache[require.resolve("./bridge.js")];
179+
require("./bridge.js");
180+
const bindingSocket = WebSocketMock.instance;
181+
bindingSocket.onopen();
182+
await bindingSocket.onmessage({
183+
data: frame(0xf5, Uint8Array.of(1)),
184+
});
185+
assert.equal(typeof clickListener, "function");
186+
187+
const dynamicButton = { id: "dynamic-binding" };
188+
clickListener({
189+
target: {
190+
closest(selector) {
191+
return selector === "[id]" ? dynamicButton : null;
192+
},
193+
},
194+
});
195+
eventPacket = new Uint8Array(bindingSocket.sent);
196+
assert.equal(eventPacket[7], 0xfc);
197+
assert.equal(
198+
new TextDecoder().decode(eventPacket.subarray(8)),
199+
dynamicButton.id,
200+
);
201+
202+
prevented = false;
203+
const sendsBeforeLink = bindingSocket.sendCount;
204+
clickListener({
205+
target: {
206+
closest() {
207+
return null;
208+
},
209+
},
210+
preventDefault() {
211+
prevented = true;
212+
},
213+
});
214+
assert.equal(prevented, false);
215+
assert.equal(bindingSocket.sendCount, sendsBeforeLink);
171216
} finally {
172217
delete globalThis.WebSocket;
173218
delete globalThis.document;
@@ -178,6 +223,7 @@ test("bridge handles commands and external script origins", async () => {
178223
delete globalThis.quickResult;
179224
delete globalThis.webui;
180225
delete globalThis.__zigWebuiCapability;
226+
delete globalThis.__zigWebuiDomBindings;
181227
delete globalThis.__zigWebuiEvents;
182228
delete globalThis.__zigWebuiToken;
183229
}

0 commit comments

Comments
 (0)