Compare commits

...

10 commits

Author SHA1 Message Date
Nurul Huda (Apon)
e7385bef86
feat(zxon): add opt field support
Some checks are pending
CI / Build (macos-latest) (push) Waiting to run
CI / Build (ubuntu-latest) (push) Waiting to run
CI / Build (windows-latest) (push) Waiting to run
CI / Test (macos-latest) (push) Waiting to run
CI / Test (ubuntu-latest) (push) Waiting to run
CI / Test (windows-latest) (push) Waiting to run
2026-07-23 19:57:44 +06:00
Nurul Huda (Apon)
caec8592a8
ci: disable webkit e2e tests as they are too flaky 2026-07-23 19:13:48 +06:00
Nurul Huda (Apon)
78f6e52ea4
feat(form): add array of form fields support 2026-07-22 11:49:05 +06:00
Nurul Huda (Apon)
2f12c28e60
ci(e2e): install all browsers 2026-07-21 14:16:52 +06:00
Nurul Huda (Apon)
3f5cecba07
ci: run e2e on all browsers 2026-07-21 14:00:36 +06:00
Nurul Huda (Apon)
93b75427c7
fix(csr): mem bound check
All of these are temp patches, we will redesign the architecture of js<>wasm bridging ideally by building packed struct directly at js side and passing to wasm to prevent serialization/deserialization
2026-07-21 13:59:22 +06:00
Nurul Huda (Apon)
07a425a103
feat(csr): add more event types 2026-07-21 05:46:26 +06:00
Nurul Huda (Apon)
9a2ee6ccfa
docs: rm jsImport from docs and unused files 2026-07-19 05:56:50 +06:00
Mohabbat
8c1f8decd1
feat(devtool): implement component hover highlight (#168) 2026-07-19 03:22:15 +06:00
Nurul Huda (Apon)
6f1fc8e1b7
fix: match whitespacing rule with html/jsx
fixes: https://github.com/ziex-dev/ziex/issues/170
2026-07-18 22:49:42 +06:00
69 changed files with 899 additions and 645 deletions

View file

@ -12,6 +12,8 @@ on:
- 'vendor/**'
- 'build.zig'
- 'build.zig.zon'
- '.github/workflows/e2e.yml'
workflow_dispatch:
inputs:
base_url:
@ -61,16 +63,22 @@ jobs:
- name: Install Playwright Browsers
working-directory: test/e2e
run: npx playwright install --with-deps chromium
run: npx playwright install --with-deps chromium webkit
- name: Run E2E Tests
- name: Run E2E Tests (Chromium)
working-directory: test/e2e
run: npx playwright test --reporter=html,github
run: npx playwright test --reporter=html,github --browser chromium
env:
CI: true
BASE_URL: ${{ inputs.base_url }}
# - name: Run E2E Tests (WebKit)
# working-directory: test/e2e
# run: npx playwright test --reporter=html,github --browser webkit
# env:
# CI: true
# BASE_URL: ${{ inputs.base_url }}
- name: Upload Playwright Report
uses: actions/upload-artifact@v4
if: always()

View file

@ -5,7 +5,7 @@
.repository = "https://github.com/ziex-dev/ziex",
.homepage = "https://ziex.dev",
.fingerprint = 0x249409a7bd1c7667,
.minimum_zig_version = "0.17.0-dev.1398+cb5635714",
.minimum_zig_version = "0.17.0-dev.1422+e863bf3be",
.dependencies = .{
// Ziex JS - The JavaScript glue code for csr/wasi/cloudflare/aws-lambda/vercel/react
.ziex_js = .{

View file

@ -14,6 +14,16 @@ pub const Component = struct {
badge: []const u8 = "",
meta: ?ComponentMeta = null,
is_native: bool = false,
/// CSS selector locating this node's root element in the inspected page,
/// e.g. `div[class="bench-row"]`. For a component/text node it is the
/// selector of its first descendant element (its rendered root). Empty when
/// the node maps to no element.
selector: []const u8 = "",
/// Which match of `selector` (in document order) this node is computed by
/// counting prior elements with the same selector while walking the
/// serialized tree in pre-order, which mirrors the DOM's document order.
/// The devtool highlights `document.querySelectorAll(selector)[occurrence]`.
occurrence: usize = 0,
};
pub const Route = struct {
@ -258,7 +268,41 @@ pub const routes = [_]Route{
.{ .method = "GET", .path = "/settings" },
};
pub fn fromSerializable(allocator: std.mem.Allocator, s: zx.Component.Serializable, path: []const u8) anyerror!Component {
/// Tracks, per CSS selector, how many matching elements have been seen so far
/// while walking the serialized tree in pre-order (mirrors DOM document order).
pub const SelectorCounters = std.StringHashMap(usize);
/// Build a CSS selector that locates an element by tag plus its most specific
/// available attribute (`id`, else `class`). The same string is counted in the
/// devtool and queried in the page, so it must be derived identically here.
fn buildSelector(allocator: std.mem.Allocator, tag: zx.ElementTag, attributes: anytype) []const u8 {
const tag_name = @tagName(tag);
if (attributes) |attrs| {
for (attrs) |a| {
if (std.mem.eql(u8, a.name, "id")) {
if (a.value) |v| if (v.len > 0)
return std.fmt.allocPrint(allocator, "{s}[id=\"{s}\"]", .{ tag_name, v }) catch tag_name;
}
}
for (attrs) |a| {
if (std.mem.eql(u8, a.name, "class")) {
if (a.value) |v| if (v.len > 0)
return std.fmt.allocPrint(allocator, "{s}[class=\"{s}\"]", .{ tag_name, v }) catch tag_name;
}
}
}
return allocator.dupe(u8, tag_name) catch tag_name;
}
fn nextOccurrence(counters: *SelectorCounters, selector: []const u8) usize {
const gop = counters.getOrPut(selector) catch return 0;
if (!gop.found_existing) gop.value_ptr.* = 0;
const occ = gop.value_ptr.*;
gop.value_ptr.* = occ + 1;
return occ;
}
pub fn fromSerializable(allocator: std.mem.Allocator, s: zx.Component.Serializable, path: []const u8, counters: *SelectorCounters) anyerror!Component {
var name: []const u8 = "unknown";
var badge: []const u8 = "";
var is_native: bool = true;
@ -273,16 +317,37 @@ pub fn fromSerializable(allocator: std.mem.Allocator, s: zx.Component.Serializab
badge = "text";
}
// Assign this node's own element locator (pre-order, before children) so
// the occurrence numbering follows document order.
var selector: []const u8 = "";
var occurrence: usize = 0;
if (s.tag) |t| {
selector = buildSelector(allocator, t, s.attributes);
occurrence = nextOccurrence(counters, selector);
}
var children: []const Component = &[_]Component{};
if (s.children) |sc| {
var children_mut = try allocator.alloc(Component, sc.len);
for (sc, 0..) |child_s, i| {
const child_path = try std.fmt.allocPrint(allocator, "{s}.{d}", .{ path, i });
children_mut[i] = try fromSerializable(allocator, child_s, child_path);
children_mut[i] = try fromSerializable(allocator, child_s, child_path, counters);
}
children = children_mut;
}
// A component/text node has no element of its own it is located by its
// first descendant element (its rendered root).
if (s.tag == null) {
for (children) |child| {
if (child.selector.len > 0) {
selector = child.selector;
occurrence = child.occurrence;
break;
}
}
}
var meta: ?ComponentMeta = null;
if (s.props) |p| {
var props_list = std.ArrayList(StateItem).empty;
@ -310,14 +375,31 @@ pub fn fromSerializable(allocator: std.mem.Allocator, s: zx.Component.Serializab
.badge = badge,
.meta = meta,
.is_native = is_native,
.selector = selector,
.occurrence = occurrence,
};
}
pub fn fromSerializableSlice(allocator: std.mem.Allocator, sc: []const zx.Component.Serializable) anyerror![]const Component {
var counters = SelectorCounters.init(allocator);
defer counters.deinit();
var children = try allocator.alloc(Component, sc.len);
for (sc, 0..) |child_s, i| {
const path = try std.fmt.allocPrint(allocator, "{d}", .{i});
children[i] = try fromSerializable(allocator, child_s, path);
children[i] = try fromSerializable(allocator, child_s, path, &counters);
}
// Synthetic wrapper roots have no element of their own; locate them by the
// first descendant element so hovering them still highlights the page.
var root_sel: []const u8 = "";
var root_occ: usize = 0;
for (children) |child| {
if (child.selector.len > 0) {
root_sel = child.selector;
root_occ = child.occurrence;
break;
}
}
var root_page = try allocator.alloc(Component, 1);
@ -328,6 +410,8 @@ pub fn fromSerializableSlice(allocator: std.mem.Allocator, sc: []const zx.Compon
.has_children = children.len > 0,
.badge = "",
.meta = null,
.selector = root_sel,
.occurrence = root_occ,
};
var root_layout = try allocator.alloc(Component, 1);
@ -338,6 +422,8 @@ pub fn fromSerializableSlice(allocator: std.mem.Allocator, sc: []const zx.Compon
.has_children = root_page.len > 0,
.badge = "",
.meta = null,
.selector = root_sel,
.occurrence = root_occ,
};
var root_component = try allocator.alloc(Component, 1);
@ -348,6 +434,8 @@ pub fn fromSerializableSlice(allocator: std.mem.Allocator, sc: []const zx.Compon
.has_children = root_layout.len > 0,
.badge = "fragment",
.meta = null,
.selector = root_sel,
.occurrence = root_occ,
};
return root_component;

View file

@ -28,8 +28,9 @@ fn fetch() void {
const host = data.host;
const path = data.current_path;
const base = if (std.mem.startsWith(u8, host, "http://") or std.mem.startsWith(u8, host, "https://")) host else std.fmt.allocPrint(zx.allocator, "http://{s}", .{host}) catch return;
const include_native = if (data.show_native_elements) "1" else "0";
const url = std.fmt.allocPrint(zx.allocator, "{s}/.well-known/_zx/devtool?path={s}&include_native={s}", .{ base, path, include_native }) catch return;
// Always fetch native elements so every component has a DOM locator; the
// `show_native_elements` setting only filters what the tree displays.
const url = std.fmt.allocPrint(zx.allocator, "{s}/.well-known/_zx/devtool?path={s}&include_native=1", .{ base, path }) catch return;
fetched = true;
_ = zx.fetch(.wasm(&onFetchText), zx.allocator, url, .{ .method = .GET }) catch {};
}
@ -74,7 +75,7 @@ pub fn ComponentTree(
return (
<div @allocator={ctx.allocator} class="component-tree">
<div class="component-list">
{for (display_components) |comp| (<ComponentItem allocator={ctx.allocator} id={comp.id} name={comp.name} has_children={comp.has_children} children={comp.children} depth={0} selected={std.mem.eql(u8, comp.id, sel)} badge={comp.badge} is_native={comp.is_native} />)}
{for (display_components) |comp| (<ComponentItem allocator={ctx.allocator} id={comp.id} name={comp.name} has_children={comp.has_children} children={comp.children} depth={0} selected={std.mem.eql(u8, comp.id, sel)} badge={comp.badge} is_native={comp.is_native} selector={comp.selector} occurrence={comp.occurrence} />)}
</div>
</div>
);
@ -204,7 +205,7 @@ fn hasNativeChild(children: []const Component) bool {
return false;
}
const ComponentItemProps = struct { id: []const u8, name: []const u8, has_children: bool, children: []const Component, depth: usize = 0, selected: bool = false, badge: []const u8 = "", is_native: bool = false };
const ComponentItemProps = struct { id: []const u8, name: []const u8, has_children: bool, children: []const Component, depth: usize = 0, selected: bool = false, badge: []const u8 = "", is_native: bool = false, selector: []const u8 = "", occurrence: usize = 0 };
fn ComponentItem(allocator: zx.Allocator, props: ComponentItemProps) zx.Component {
const sel = selected_component;
@ -212,6 +213,7 @@ fn ComponentItem(allocator: zx.Allocator, props: ComponentItemProps) zx.Componen
const padding = props.depth * 24 + 16;
const style_str = std.fmt.allocPrint(allocator, "padding-left: {d}px", .{padding}) catch "";
const is_selected = std.mem.eql(u8, props.id, sel);
const idx_str = std.fmt.allocPrint(allocator, "{d}", .{props.occurrence}) catch "0";
const item_class = if (is_selected) "component-root" else "component-item";
const name_class = if (is_selected) "component-name-root" else "component-name";
const group_class = getComponentGroupClass(props.name, props.has_children, props.children);
@ -221,7 +223,7 @@ fn ComponentItem(allocator: zx.Allocator, props: ComponentItemProps) zx.Componen
return (
<div @allocator={allocator} class={group_class}>
<div class={item_class} style={style_str}>
<button value={props.id} onclick={handleComponentClick} class="component-select-btn-leaf">
<button value={props.id} data-sel={props.selector} data-idx={idx_str} onclick={handleComponentClick} class="component-select-btn-leaf">
<span class="tree-arrow-spacer"></span>
<span class="bracket">{"<"}</span>
<span class={name_class}>{props.name}</span>
@ -243,7 +245,7 @@ fn ComponentItem(allocator: zx.Allocator, props: ComponentItemProps) zx.Componen
<label for={dom_id} class="component-toggle-label">
<span class="tree-arrow">{icons.ChevronRight(allocator)}</span>
</label>
<button value={props.id} onclick={handleComponentClick} class="component-select-btn">
<button value={props.id} data-sel={props.selector} data-idx={idx_str} onclick={handleComponentClick} class="component-select-btn">
<span class="bracket">{"<"}</span>
<span class={name_class}>{props.name}</span>
<span class="bracket">{">"}</span>
@ -252,7 +254,7 @@ fn ComponentItem(allocator: zx.Allocator, props: ComponentItemProps) zx.Componen
</div>
<div class="component-children">
{for (props.children) |child| (
<ComponentItem allocator={allocator} id={child.id} name={child.name} has_children={child.has_children} children={child.children} depth={props.depth + 1} selected={std.mem.eql(u8, child.id, sel)} badge={child.badge} is_native={child.is_native} />
<ComponentItem allocator={allocator} id={child.id} name={child.name} has_children={child.has_children} children={child.children} depth={props.depth + 1} selected={std.mem.eql(u8, child.id, sel)} badge={child.badge} is_native={child.is_native} selector={child.selector} occurrence={child.occurrence} />
)}
</div>
</div>
@ -269,7 +271,7 @@ fn ComponentItem(allocator: zx.Allocator, props: ComponentItemProps) zx.Componen
<label for={dom_id} class="component-toggle-label">
<span class="tree-arrow">{icons.ChevronRight(allocator)}</span>
</label>
<button value={props.id} onclick={handleComponentClick} class="component-select-btn">
<button value={props.id} data-sel={props.selector} data-idx={idx_str} onclick={handleComponentClick} class="component-select-btn">
<span class="bracket">{"<"}</span>
<span class={name_class}>{props.name}</span>
<span class="bracket">{">"}</span>
@ -278,7 +280,7 @@ fn ComponentItem(allocator: zx.Allocator, props: ComponentItemProps) zx.Componen
</div>
<div class="component-children">
{for (props.children) |child| (
<ComponentItem allocator={allocator} id={child.id} name={child.name} has_children={child.has_children} children={child.children} depth={props.depth + 1} selected={std.mem.eql(u8, child.id, sel)} badge={child.badge} is_native={child.is_native} />
<ComponentItem allocator={allocator} id={child.id} name={child.name} has_children={child.has_children} children={child.children} depth={props.depth + 1} selected={std.mem.eql(u8, child.id, sel)} badge={child.badge} is_native={child.is_native} selector={child.selector} occurrence={child.occurrence} />
)}
</div>
</div>
@ -294,7 +296,7 @@ fn ComponentItem(allocator: zx.Allocator, props: ComponentItemProps) zx.Componen
<label for={dom_id} class="component-toggle-label">
<span class="tree-arrow">{icons.ChevronRight(allocator)}</span>
</label>
<button value={props.id} onclick={handleComponentClick} class="component-select-btn">
<button value={props.id} data-sel={props.selector} data-idx={idx_str} onclick={handleComponentClick} class="component-select-btn">
<span class="bracket">{"<"}</span>
<span class={name_class}>{props.name}</span>
<span class="bracket">{">"}</span>
@ -303,7 +305,7 @@ fn ComponentItem(allocator: zx.Allocator, props: ComponentItemProps) zx.Componen
</div>
<div class="component-children">
{for (props.children) |child| (
<ComponentItem allocator={allocator} id={child.id} name={child.name} has_children={child.has_children} children={child.children} depth={props.depth + 1} selected={std.mem.eql(u8, child.id, sel)} badge={child.badge} is_native={child.is_native} />
<ComponentItem allocator={allocator} id={child.id} name={child.name} has_children={child.has_children} children={child.children} depth={props.depth + 1} selected={std.mem.eql(u8, child.id, sel)} badge={child.badge} is_native={child.is_native} selector={child.selector} occurrence={child.occurrence} />
)}
</div>
</div>

View file

@ -61,6 +61,7 @@ async function syncLocationFromUrl(href: string): Promise<boolean> {
}
async function refreshFromNavigation(href?: string): Promise<void> {
await clearInspectedComponentHighlight();
const updatedFromUrl = typeof href === "string" ? await syncLocationFromUrl(href) : false;
if (!updatedFromUrl) {
await syncInspectedPageLocation();
@ -68,6 +69,116 @@ async function refreshFromNavigation(href?: string): Promise<void> {
await window.__zx_dev_reinit?.();
}
function evalInInspectedWindow<T = unknown>(expression: string): Promise<T | undefined> {
const chromeApi = (globalThis as any).chrome;
if (!chromeApi?.devtools?.inspectedWindow?.eval) return Promise.resolve(undefined);
return new Promise<T | undefined>((resolve) => {
chromeApi.devtools.inspectedWindow.eval(
expression,
(result: T | undefined, exceptionInfo: { isException?: boolean } | undefined) => {
if (exceptionInfo?.isException) {
resolve(undefined);
return;
}
resolve(result);
}
);
});
}
/**
* Build a script that, when eval'd in the inspected page, positions a highlight
* overlay over a component's root element.
*
* The devtool derives a CSS `selector` and an `occurrence` index for each
* component from the serialized tree (see data.zig). The inspected page needs no
* special markup: we locate the element with
* `document.querySelectorAll(selector)[occurrence]`. Pass a null/empty selector
* to hide the overlay.
*/
function inspectedHighlightScript(selector: string | null, occurrence: number): string {
const selLiteral = JSON.stringify(selector);
const occLiteral = JSON.stringify(occurrence | 0);
return `(() => {
const key = '__ZX_DEVTOOL_HOVER_OVERLAY__';
const root = window;
const state = root[key] || (root[key] = { overlay: null, activeId: null });
const ensureOverlay = () => {
if (state.overlay && state.overlay.isConnected) return state.overlay;
const overlay = document.createElement('div');
overlay.style.position = 'fixed';
overlay.style.zIndex = '2147483647';
overlay.style.pointerEvents = 'none';
overlay.style.border = '2px solid #41b883';
overlay.style.background = 'rgba(65, 184, 131, 0.18)';
overlay.style.borderRadius = '4px';
overlay.style.boxSizing = 'border-box';
overlay.style.display = 'none';
document.documentElement.appendChild(overlay);
state.overlay = overlay;
return overlay;
};
const hideOverlay = () => {
if (state.overlay) state.overlay.style.display = 'none';
state.activeId = null;
return false;
};
const selector = ${selLiteral};
const occurrence = ${occLiteral};
if (!selector) return hideOverlay();
let nodes;
try { nodes = document.querySelectorAll(selector); } catch { return hideOverlay(); }
const el = nodes[occurrence];
if (!el) return hideOverlay();
const rect = el.getBoundingClientRect();
if (!rect || (rect.width <= 0 && rect.height <= 0)) return hideOverlay();
const overlay = ensureOverlay();
overlay.style.display = 'block';
overlay.style.left = rect.left + 'px';
overlay.style.top = rect.top + 'px';
overlay.style.width = Math.max(1, rect.width) + 'px';
overlay.style.height = Math.max(1, rect.height) + 'px';
state.activeId = selector + '#' + occurrence;
return true;
})()`;
}
async function highlightInspectedComponent(selector: string, occurrence: number): Promise<void> {
await evalInInspectedWindow(inspectedHighlightScript(selector, occurrence));
}
async function clearInspectedComponentHighlight(): Promise<void> {
await evalInInspectedWindow(inspectedHighlightScript(null, 0));
}
let hoveredKey: string | null = null;
function getHoveredComponentButton(target: EventTarget | null): HTMLElement | null {
if (!(target instanceof Element)) return null;
return target.closest('.component-select-btn, .component-select-btn-leaf');
}
async function startHover(selector: string, occurrence: number): Promise<void> {
if (!selector) return;
const key = selector + '#' + occurrence;
if (hoveredKey === key) return;
hoveredKey = key;
await highlightInspectedComponent(selector, occurrence);
}
async function stopHover(): Promise<void> {
if (!hoveredKey) return;
hoveredKey = null;
await clearInspectedComponentHighlight();
}
async function main() {
await syncInspectedPageLocation();
await init({ kv: kvBindings });
@ -104,3 +215,24 @@ window.addEventListener('zx-navigation', async (e: Event) => {
await refreshFromNavigation(href);
}
});
document.addEventListener('mouseover', async (e: MouseEvent) => {
const btn = getHoveredComponentButton(e.target);
if (!btn) return;
const selector = btn.getAttribute('data-sel');
if (!selector) return;
const occurrence = parseInt(btn.getAttribute('data-idx') || '0', 10) || 0;
await startHover(selector, occurrence);
});
document.addEventListener('mouseout', async (e: MouseEvent) => {
const from = getHoveredComponentButton(e.target);
if (!from) return;
const to = getHoveredComponentButton(e.relatedTarget);
if (to) return;
await stopHover();
});
window.addEventListener('blur', async () => {
await stopHover();
});

View file

@ -69,28 +69,22 @@ export function getMemoryView(): Uint8Array {
return memoryView!;
}
/**
* Cache for WASM string reads keyed by (ptr, len).
* Attribute names / tag names are Zig string literals whose pointers are
* stable for the lifetime of the module, so caching avoids repeated
* TextDecoder.decode calls for the same pointer+length pair.
*/
const stringCache = new Map<number, string>();
function stringCacheKey(ptr: number, len: number): number { return ptr * 0x10000 + len; }
/** Read a string from WASM memory */
/** Read a string from WASM memory. Treats ptr/len as u32 (WASM i32 is signed in JS). */
export function readString(ptr: number, len: number): string {
const key = stringCacheKey(ptr, len);
const cached = stringCache.get(key);
if (cached !== undefined) return cached;
const str = textDecoder.decode(getMemoryView().subarray(ptr, ptr + len));
stringCache.set(key, str);
return str;
const start = ptr >>> 0;
const length = len >>> 0;
if (length === 0) return "";
const view = getMemoryView();
if (start + length > view.byteLength) return "";
return textDecoder.decode(view.subarray(start, start + length));
}
/** Write bytes to WASM memory at a specific location */
export function writeBytes(ptr: number, data: Uint8Array): void {
getMemoryView().set(data, ptr);
const start = ptr >>> 0;
const view = getMemoryView();
if (start + data.length > view.byteLength) return;
view.set(data, start);
}
export function wrapPromisingExport<F extends (...args: any[]) => any>(fn: F | undefined): F | undefined {
@ -214,7 +208,8 @@ export class ZxBridgeCore {
): void {
const handler = this.#fetchCompleteHandler;
const encoded = typeof body === 'string' ? textEncoder.encode(body) : body;
const ptr = this._alloc(encoded.length);
const ptr = this._alloc(encoded.length) >>> 0;
if (!ptr && encoded.length > 0) return;
writeBytes(ptr, encoded);
invokeWasmExport(handler, fetchId, statusCode, ptr, encoded.length, isError ? 1 : 0);
}
@ -257,14 +252,19 @@ export class ZxBridgeCore {
writeBytesToWasm(data: Uint8Array): { ptr: number; len: number } {
if (data.length === 0) return { ptr: 0, len: 0 };
const ptr = this._alloc(data.length);
const ptr = this._alloc(data.length) >>> 0;
if (!ptr) return { ptr: 0, len: 0 };
writeBytes(ptr, data);
return { ptr, len: data.length };
}
/** Log a message from WASM at the given level (0=error, 1=warn, 2=info, 3=debug) */
static log(level: number, ptr: number, len: number): void {
const msg = textDecoder.decode(getMemoryView().subarray(ptr, ptr + len));
const start = ptr >>> 0;
const length = len >>> 0;
const view = getMemoryView();
if (start + length > view.byteLength) return;
const msg = textDecoder.decode(view.subarray(start, start + length));
switch (level) {
case 0: console.error(msg); break;
case 1: console.warn(msg); break;
@ -336,11 +336,12 @@ export function writeBytesOut(
}
const alloc = allocRef.current;
if (!alloc) return -1;
const ptr = alloc(data.length);
const ptr = alloc(data.length) >>> 0;
if (!ptr) return -1;
const buffer = getMemory().buffer;
if (ptr + data.length > buffer.byteLength) return -1;
new Uint8Array(buffer, ptr, data.length).set(data);
new DataView(buffer).setUint32(outPtrAddr, ptr, true);
new DataView(buffer).setUint32(outPtrAddr >>> 0, ptr, true);
return data.length;
}

View file

@ -176,10 +176,13 @@ export class ZxBridge extends ZxBridgeCore {
const ws = this.#websockets.get(wsId);
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const memory = getMemoryView();
const start = dataPtr >>> 0;
const length = dataLen >>> 0;
if (start + length > memory.byteLength) return;
if (isBinary) {
ws.send(memory.slice(dataPtr, dataPtr + dataLen));
ws.send(memory.slice(start, start + length));
} else {
ws.send(textDecoder.decode(memory.subarray(dataPtr, dataPtr + dataLen)));
ws.send(textDecoder.decode(memory.subarray(start, start + length)));
}
}
@ -637,6 +640,11 @@ const DELEGATED_EVENTS = [
{ domType: 'touchend', eventTypeId: 16 },
{ domType: 'touchmove', eventTypeId: 17 },
{ domType: 'scroll', eventTypeId: 18 },
{ domType: 'wheel', eventTypeId: 19 },
{ domType: 'pointerdown', eventTypeId: 20 },
{ domType: 'pointermove', eventTypeId: 21 },
{ domType: 'pointerup', eventTypeId: 22 },
{ domType: 'pointercancel', eventTypeId: 23 },
] as const;
const eventHandlerModes = new Map<bigint, number>();
@ -661,7 +669,10 @@ export function initEventDelegation(bridge: ZxBridge, rootSelector: string = 'bo
}
};
const options = { passive: delegatedEvent.domType.startsWith('touch') || delegatedEvent.domType === 'scroll' };
const passive =
delegatedEvent.domType.startsWith('touch') ||
delegatedEvent.domType === 'scroll';
const options = { passive };
root.addEventListener(delegatedEvent.domType, listener, options);
// @ts-ignore
removers.push(() => root.removeEventListener(delegatedEvent.domType, listener, options));

View file

@ -40,11 +40,19 @@ export class ZxWasiBridge {
}
#readString(ptr: number, len: number): string {
return decoder.decode(this.#view().subarray(ptr, ptr + len));
const start = ptr >>> 0;
const length = len >>> 0;
if (length === 0) return "";
const view = this.#view();
if (start + length > view.byteLength) return "";
return decoder.decode(view.subarray(start, start + length));
}
#writeBytes(ptr: number, data: Uint8Array): void {
this.#view().set(data, ptr);
const start = ptr >>> 0;
const view = this.#view();
if (start + data.length > view.byteLength) return;
view.set(data, start);
}
log(level: number, ptr: number, len: number): void {

View file

@ -1,24 +0,0 @@
pub fn AllocatorDemo(ctx: zx.PageContext) zx.Component {
return (
<html @allocator={ctx.arena}>
<head>
<title>My Page</title>
</head>
<body>
<MyComponent />
</body>
</html>
);
}
fn MyComponent(allocator: zx.Allocator) zx.Component {
const someText = "This text will be allocated and escaped";
return (
<div @allocator={allocator}>
{someText}
</div>
);
}
const zx = @import("zx");

View file

@ -1,26 +0,0 @@
pub fn CardDemo(allocator: zx.Allocator) zx.Component {
return (
<main @allocator={allocator}>
<Card title="Welcome">
<p>This is the card content.</p>
<button>Click me</button>
</Card>
</main>
);
}
const CardProps = struct {
title: []const u8,
children: zx.Component,
};
fn Card(allocator: zx.Allocator, props: CardProps) zx.Component {
return (
<div @allocator={allocator} class="card">
<h2>{props.title}</h2>
<div class="card-body">{props.children}</div>
</div>
);
}
const zx = @import("zx");

View file

@ -1,11 +0,0 @@
pub fn Page(allocator: zx.Allocator) zx.Component {
const greeting = zx.Component{ .text = "Hello!" };
return (
<section @allocator={allocator}>
{greeting}
</section>
);
}
const zx = @import("zx");

View file

@ -1,26 +0,0 @@
pub fn ButtonDemo(ctx: zx.PageContext) zx.Component {
const allocator = ctx.arena;
return (
<main @allocator={allocator}>
<Button title="Submit" class="primary-btn" />
<Button title="Cancel" />
<Button />
</main>
);
}
const ButtonProps = struct {
title: []const u8 = "Click Me",
class: []const u8 = "btn",
};
fn Button(allocator: zx.Allocator, props: ButtonProps) zx.Component {
return (
<button @allocator={allocator} class={props.class}>
{props.title}
</button>
);
}
const zx = @import("zx");

View file

@ -1,13 +0,0 @@
pub fn Page(allocator: zx.Allocator) zx.Component {
visit_count += 1;
return (
<main @allocator={allocator}>
<CounterComponent @rendering={.react} visit_count={visit_count} />
</main>
);
}
var visit_count: u64 = 0;
const zx = @import("zx");
const CounterComponent = @jsImport("./react.tsx");

View file

@ -1,12 +0,0 @@
pub fn UserProfile(ctx: zx.PageContext) zx.Component {
const user_id = ctx.request.params.get("id") orelse "unknown";
return (
<main @allocator={ctx.arena}>
<h1>User Profile</h1>
<p>User ID: {user_id}</p>
</main>
);
}
const zx = @import("zx");

View file

@ -1,11 +0,0 @@
pub fn RawHtml(allocator: zx.Allocator) zx.Component {
return (
<main @allocator={allocator}>
<div @escaping={.none}>
<strong>This HTML is rendered directly</strong>
</div>
</main>
);
}
const zx = @import("zx");

View file

@ -1,25 +0,0 @@
pub fn UserList(arena: zx.Allocator) zx.Component {
const users = [_]struct { name: []const u8, role: UserRole }{
.{ .name = "John", .role = .admin },
.{ .name = "Jane", .role = .member },
.{ .name = "Jim", .role = .guest },
};
return (
<main @allocator={arena}>
{for (users) |user| (
<div>
<p>{user.name}</p>
{switch (user.role) {
.admin => (<span>Admin</span>),
.member => (<span>Member</span>),
.guest => (<span>Guest</span>),
}}
</div>
)}
</main>
);
}
const zx = @import("zx");
const UserRole = enum { admin, member, guest };

View file

@ -1,15 +0,0 @@
pub fn FormatDemo(allocator: zx.Allocator) zx.Component {
const count = 42;
const hex_value = 255;
const percentage = 75;
return (
<section @allocator={allocator}>
<p>Count: {count}</p>
<p>Hex: 0x{hex_value}</p>
<p>Percentage: {percentage}%</p>
</section>
);
}
const zx = @import("zx");

View file

@ -1,18 +0,0 @@
pub fn FragmentDemo(allocator: zx.Allocator) zx.Component {
return (
<main @allocator={allocator}>
<Header />
</main>
);
}
fn Header(allocator: zx.Allocator) zx.Component {
return (
<>
<h1 @allocator={allocator}>Welcome</h1>
<p>Multiple elements without a wrapper</p>
</>
);
}
const zx = @import("zx");

View file

@ -1,24 +0,0 @@
pub fn Conditional(allocator: zx.Allocator) zx.Component {
const is_admin = true;
const is_logged_in = false;
return (
<main @allocator={allocator}>
<section>
{if (is_admin) (<p>Admin</p>) else (<p>User</p>)}
</section>
<section>
{if (is_admin) ("Powerful") else ("Powerless")}
</section>
<section>
{if (is_logged_in) (
<p>Welcome, User!</p>
) else (
<p>Please log in to continue.</p>
)}
</section>
</main>
);
}
const zx = @import("zx");

View file

@ -1,28 +0,0 @@
pub fn ImportDemo(allocator: zx.Allocator) zx.Component {
return (
<main @allocator={allocator}>
<Button title="Click me" />
<Card title="Welcome">
<p>Card content here</p>
</Card>
</main>
);
}
const zx = @import("zx");
// Define components in the same file
const ButtonProps = struct { title: []const u8 };
fn Button(a: zx.Allocator, props: ButtonProps) zx.Component {
return (<button @allocator={a} class="btn">{props.title}</button>);
}
const CardProps = struct { title: []const u8, children: zx.Component };
fn Card(a: zx.Allocator, props: CardProps) zx.Component {
return (
<div @allocator={a} class="card">
<h2>{props.title}</h2>
{props.children}
</div>
);
}

View file

@ -1,42 +0,0 @@
pub fn Playground(allocator: zx.Allocator) zx.Component {
const is_loading = true;
var i: usize = 0;
return (
<main @allocator={allocator}>
<h1>Hello, Ziex!</h1>
{for (users) |user| (
<Profile name={user.name} age={user.age} role={user.role} />
)}
{if (is_loading) (<p>Loading...</p>) else (<p>Loaded</p>)}
{while (i < 5) : (i += 1) (<i>{i}</i>)}
</main>
);
}
fn Profile(ctx: *zx.ComponentCtx(User)) zx.Component {
return (
<div @allocator={ctx.allocator}>
<h3>{ctx.props.name}</h3>
<div>{ctx.props.age}</div>
<strong>
{switch (ctx.props.role) {
.admin => "Admin",
.member => "Member",
}}
</strong>
</div>
);
}
const User = struct { name: []const u8, age: u32, role: enum { admin, member } };
const users = [_]User{
.{ .name = "John", .age = 20, .role = .admin },
.{ .name = "Jane", .age = 21, .role = .member },
};
const zx = @import("zx");

View file

@ -1,13 +0,0 @@
import React, { useState } from "react";
export default function Page(props: { html: string, visit_count: number }) {
const [visit_count, setVisitCount] = useState(props.visit_count);
return (
<main>
<button onClick={() => setVisitCount(visit_count + 1)}>Increment</button>
<button onClick={() => setVisitCount(visit_count - 1)}>Decrement</button>
<p>Visit Count: {visit_count}</p>
</main>
);
}

View file

@ -1,12 +0,0 @@
pub fn ReactDemo(allocator: zx.Allocator) zx.Component {
const max_count = 10;
return (
<main @allocator={allocator}>
<CounterComponent @rendering={.react} max_count={max_count} />
</main>
);
}
const zx = @import("zx");
const CounterComponent = @jsImport("react.tsx");

View file

@ -1,16 +0,0 @@
// This example shows a page component
pub fn Page(ctx: zx.PageContext) zx.Component {
const allocator = ctx.arena;
// Access request data (e.g., query params, headers, etc.)
// const query = ctx.request.query() catch unreachable;
return (
<main @allocator={allocator}>
<h1>Hello, World!</h1>
<p>Welcome to the page</p>
</main>
);
}
const zx = @import("zx");

View file

@ -1,31 +0,0 @@
pub fn RoleSwitch(allocator: zx.Allocator) zx.Component {
const user_swtc = users[0];
return (
<main @allocator={allocator}>
<section>
{switch (user_swtc.user_type) {
.admin => ("Admin"),
.member => ("Member"),
}}
</section>
<section>
{switch (user_swtc.user_type) {
.admin => (<p>Powerful</p>),
.member => (<p>Powerless</p>),
}}
</section>
</main>
);
}
const zx = @import("zx");
const UserType = enum { admin, member };
const User = struct { name: []const u8, age: u32, user_type: UserType };
const users = [_]User{
.{ .name = "John", .age = 20, .user_type = .admin },
.{ .name = "Jane", .age = 21, .user_type = .member },
};

View file

@ -1,35 +0,0 @@
pub fn Expressions(allocator: zx.Allocator) !zx.Component {
const string_val = "hello";
const int_val: i32 = 42;
const float_val: f32 = 3.14;
const bool_true = true;
const bool_false = false;
const optional_val: ?[]const u8 = "present";
const optional_null: ?[]const u8 = null;
const InputType = enum { text, number, checkbox };
const enum_val = InputType.text;
const component_val = (<p>hi</p>);
const component_array_val = try allocator.alloc(zx.Component, 2);
component_array_val[0] = (<li>hi</li>);
component_array_val[1] = (<li>hello</li>);
return (
<form @allocator={allocator}>
<p>String: {string_val}</p>
<p>Integer: {int_val}</p>
<p>Float: {float_val}</p>
<p>Boolean: {bool_true}</p>
<p>Boolean: {bool_false}</p>
<p>Optional: {optional_val}</p>
<p>Optional: {optional_null}</p>
<p>Enum: {enum_val}</p>
<p>Component: {component_val}</p>
<ol>Component Array: {component_array_val}</ol>
</form>
);
}
const zx = @import("zx");

View file

@ -1,13 +0,0 @@
pub fn Counter(allocator: zx.Allocator) zx.Component {
var i: usize = 0;
return (
<ul @allocator={allocator}>
{while (i < 3) : (i += 1) (
<li>Item {i}</li>
)}
</ul>
);
}
const zx = @import("zx");

View file

@ -1,9 +1,6 @@
const features = [_]Feature{
.{ .icon_fn = icons.Lightning, .title = "Fast Performance", .description = "Significantly faster at SSR than many other frameworks. Optimized for speed and low latency." },
// .{ .icon_fn = icons.Shield, .title = "Deterministic Safety", .description = "Built on Zig's safety features. Zero undefined behavior at runtime. Predictable and reliable." },
.{ .icon_fn = icons.Code, .title = "Familiar Syntax", .description = "Familiar plain HTML-style markup, with full access to Zig's control flow." },
// .{ .icon_fn = icons.Globe, .title = "Edge First", .description = "Native support for WASI and Edge runtimes. Deploy to Cloudflare Workers or Vercel Edge with zero config." },
// .{ .icon_fn = icons.Package, .title = "Zero Dependencies", .description = "Self-contained framework. No node_modules, no external runtimes. Just one small binary." },
.{ .icon_fn = icons.Folder, .title = "File System Routing", .description = "Just files in folders to create routes." },
.{ .icon_fn = icons.Server, .title = "API Routes", .description = "Create API endpoints by adding route.zig files to your project." },
.{ .icon_fn = icons.Monitor, .title = "Hybrid Rendering", .description = "Client-side rendering for interactive experiences when you need it." },
@ -27,89 +24,12 @@ const benchmark_cwv_rows = [_]BenchmarkRow{
.{ .label = "Leptos", .value = 27, .display = "27%", .url = "https://leptos.dev" },
};
// Import benchmark data from auto-generated file
const bench_data = @import("bench.zon");
const benchfig = @import("benchfig.zon");
const bench_run = bench_data.run;
const bench_results = bench_data.results;
const bench_len = @typeInfo(@TypeOf(bench_results)).@"struct".field_types.len;
// Helper function to format large numbers (e.g., 25153 -> "25k")
fn formatLargeNumber(n: f64) []const u8 {
if (n >= 1000) {
const k = n / 1000;
if (k >= 10) {
return std.fmt.comptimePrint("{d}k", .{@as(u32, @intFromFloat(k))});
} else {
return std.fmt.comptimePrint("{d:.1}k", .{k});
}
}
return std.fmt.comptimePrint("{d}", .{@as(u32, @intFromFloat(n))});
}
// Helper function to format memory with 1 decimal place (e.g., 9.8 MB)
fn formatMemory(mb: f64) []const u8 {
return std.fmt.comptimePrint("{d:.1} MB", .{mb});
}
// Helper function to format requests per second with comma separator
fn formatRequests(n: f64) []const u8 {
const int_val = @as(u32, @intFromFloat(n));
if (int_val >= 10000) {
const thousands = int_val / 1000;
const remainder = int_val % 1000;
return std.fmt.comptimePrint("{d},{d:0>3}", .{ thousands, remainder });
}
if (int_val >= 1000) {
const thousands = int_val / 1000;
const remainder = int_val % 1000;
return std.fmt.comptimePrint("{d},{d}", .{ thousands, remainder });
}
return std.fmt.comptimePrint("{d}", .{int_val});
}
// Helper function to format latency in milliseconds
fn formatLatency(ms: f64) []const u8 {
return std.fmt.comptimePrint("{d:.2} ms", .{ms});
}
// Helper function to format build time in seconds
fn formatBuildTime(s: f64) []const u8 {
const int_s = @as(u32, @intFromFloat(s));
if (int_s >= 60) {
const mins = int_s / 60;
const secs = int_s % 60;
return std.fmt.comptimePrint("{d}m {d}s", .{ mins, secs });
}
return std.fmt.comptimePrint("{d}s", .{int_s});
}
// Helper function to format image size (e.g., "18 MB", "1.0 GB")
fn formatImageSize(mb: f64) []const u8 {
if (mb >= 1000) {
const gb = mb / 1000;
return std.fmt.comptimePrint("{d:.1} GB", .{gb});
}
return std.fmt.comptimePrint("{d} MB", .{@as(u32, @intFromFloat(mb))});
}
// Helper function to format cold start time in milliseconds
fn formatColdStart(ms: f64) []const u8 {
return std.fmt.comptimePrint("{d} ms", .{@as(u32, @intFromFloat(ms))});
}
// Helper function to format CPU peak percentage
fn formatCpuPeak(pct: f64) []const u8 {
return std.fmt.comptimePrint("{d:.1}%", .{pct});
}
// Helper function to format binary size in MB
fn formatBinarySize(mb: f64) []const u8 {
if (mb == 0) return "N/A";
return std.fmt.comptimePrint("{d:.1} MB", .{mb});
}
// Map framework id to its website URL
fn getFrameworkUrl(comptime id: []const u8) []const u8 {
if (std.mem.eql(u8, id, "jetzig")) return "https://jetzig.dev";
if (std.mem.eql(u8, id, "leptos")) return "https://leptos.dev";
@ -136,13 +56,13 @@ fn buildSsrRows() [bench_len]BenchmarkRow {
rows[i] = .{
.label = d.label,
.value = d.requests_per_sec,
.display = formatLargeNumber(d.requests_per_sec),
.display = util.formatLargeNumber(d.requests_per_sec),
.url = getFrameworkUrl(d.id),
.tooltip_metrics = &[_]TooltipMetric{
.{ .label = "Framework", .value = d.label },
.{ .label = "Requests/sec", .value = formatRequests(d.requests_per_sec) },
.{ .label = "P50 Latency", .value = formatLatency(d.p50_latency_ms) },
.{ .label = "P99 Latency", .value = formatLatency(d.p99_latency_ms) },
.{ .label = "Requests/sec", .value = util.formatRequests(d.requests_per_sec) },
.{ .label = "P50 Latency", .value = util.formatLatency(d.p50_latency_ms) },
.{ .label = "P99 Latency", .value = util.formatLatency(d.p99_latency_ms) },
},
};
}
@ -159,12 +79,12 @@ fn buildMemoryRows() [bench_len]BenchmarkRow {
rows[i] = .{
.label = d.label,
.value = d.peak_memory_mb,
.display = formatMemory(d.peak_memory_mb),
.display = util.formatMemory(d.peak_memory_mb),
.url = getFrameworkUrl(d.id),
.tooltip_metrics = &[_]TooltipMetric{
.{ .label = "Framework", .value = d.label },
.{ .label = "Idle Memory", .value = formatMemory(d.idle_memory_mb) },
.{ .label = "Peak Memory", .value = formatMemory(d.peak_memory_mb) },
.{ .label = "Idle Memory", .value = util.formatMemory(d.idle_memory_mb) },
.{ .label = "Peak Memory", .value = util.formatMemory(d.peak_memory_mb) },
},
};
}
@ -181,11 +101,11 @@ fn buildBuildTimeRows() [bench_len]BenchmarkRow {
rows[i] = .{
.label = d.label,
.value = d.build_time_s,
.display = formatBuildTime(d.build_time_s),
.display = util.formatBuildTime(d.build_time_s),
.url = getFrameworkUrl(d.id),
.tooltip_metrics = &[_]TooltipMetric{
.{ .label = "Framework", .value = d.label },
.{ .label = "Build Time", .value = formatBuildTime(d.build_time_s) },
.{ .label = "Build Time", .value = util.formatBuildTime(d.build_time_s) },
},
};
}
@ -202,12 +122,12 @@ fn buildColdStartRows() [bench_len]BenchmarkRow {
rows[i] = .{
.label = d.label,
.value = d.cold_start_ms,
.display = formatColdStart(d.cold_start_ms),
.display = util.formatColdStart(d.cold_start_ms),
.url = getFrameworkUrl(d.id),
.tooltip_metrics = &[_]TooltipMetric{
.{ .label = "Framework", .value = d.label },
.{ .label = "Cold Start", .value = formatColdStart(d.cold_start_ms) },
.{ .label = "Image Size", .value = formatImageSize(d.image_mb) },
.{ .label = "Cold Start", .value = util.formatColdStart(d.cold_start_ms) },
.{ .label = "Image Size", .value = util.formatImageSize(d.image_mb) },
},
};
}
@ -224,12 +144,12 @@ fn buildImageSizeRows() [bench_len]BenchmarkRow {
rows[i] = .{
.label = d.label,
.value = d.image_mb,
.display = formatImageSize(d.image_mb),
.display = util.formatImageSize(d.image_mb),
.url = getFrameworkUrl(d.id),
.tooltip_metrics = &[_]TooltipMetric{
.{ .label = "Framework", .value = d.label },
.{ .label = "Image Size", .value = formatImageSize(d.image_mb) },
.{ .label = "Binary Size", .value = formatBinarySize(d.binary_mb) },
.{ .label = "Image Size", .value = util.formatImageSize(d.image_mb) },
.{ .label = "Binary Size", .value = util.formatBinarySize(d.binary_mb) },
},
};
}
@ -246,13 +166,13 @@ fn buildCpuUsageRows() [bench_len]BenchmarkRow {
rows[i] = .{
.label = d.label,
.value = d.cpu_avg_pct,
.display = formatCpuPeak(d.cpu_avg_pct),
.display = util.formatCpuPeak(d.cpu_avg_pct),
.url = getFrameworkUrl(d.id),
.tooltip_metrics = &[_]TooltipMetric{
.{ .label = "Framework", .value = d.label },
.{ .label = "Avg CPU", .value = formatCpuPeak(d.cpu_avg_pct) },
.{ .label = "Peak CPU", .value = formatCpuPeak(d.cpu_peak_pct) },
.{ .label = "Requests/sec", .value = formatRequests(d.requests_per_sec) },
.{ .label = "Avg CPU", .value = util.formatCpuPeak(d.cpu_avg_pct) },
.{ .label = "Peak CPU", .value = util.formatCpuPeak(d.cpu_peak_pct) },
.{ .label = "Requests/sec", .value = util.formatRequests(d.requests_per_sec) },
},
};
}
@ -349,7 +269,6 @@ pub fn Page(ctx: zx.PageContext) zx.Component {
break :blk 1970 + years_since_epoch;
};
// const overview_code = @embedFile("./examples/overview.zx");
const feature_examples = @embedFile("./examples/feature_examples.zx");
const control_tabs = [_]CodeTab{

View file

@ -372,9 +372,11 @@ pub fn Page(ctx: zx.PageContext) !zx.Component {
</section>
<section id="importing" class="section">
<h2>Importing</h2>
<p>ZX supports importing components and modules from various sources. You can import ZX components from other files, React/TSX components for client-side interactivity, and standard Zig modules.</p>
<p>
ZX supports importing components and modules from various sources. You can import ZX components from other files,
and standard Zig modules. You can't import .zx file to standard .zig file, in case you need to do that you can name your .zig file .zx such as route.zx for example when you need to import things from a .zx file.
</p>
<ExampleBlock id="importing" zx_code={zx_example_importing} zig_code={zig_example_importing} html_code={html_example_importing} filename="import_demo.zx" />
<p>Use <code>@jsImport</code> for React/TSX components and the standard <code>@import</code> for ZX and Zig files.</p>
</section>
<section id="dynamic-routes" class="section">
<h2>Dynamic Routes</h2>

View file

@ -617,3 +617,69 @@ fn logTimingFmt(comptime label: []const u8, args: anytype, elapsed_ns: u64) void
elapsed_ms, color_reset,
});
}
pub fn formatLargeNumber(n: f64) []const u8 {
if (n >= 1000) {
const k = n / 1000;
if (k >= 10) {
return std.fmt.comptimePrint("{d}k", .{@as(u32, @intFromFloat(k))});
} else {
return std.fmt.comptimePrint("{d:.1}k", .{k});
}
}
return std.fmt.comptimePrint("{d}", .{@as(u32, @intFromFloat(n))});
}
pub fn formatMemory(mb: f64) []const u8 {
return std.fmt.comptimePrint("{d:.1} MB", .{mb});
}
pub fn formatRequests(n: f64) []const u8 {
const int_val = @as(u32, @intFromFloat(n));
if (int_val >= 10000) {
const thousands = int_val / 1000;
const remainder = int_val % 1000;
return std.fmt.comptimePrint("{d},{d:0>3}", .{ thousands, remainder });
}
if (int_val >= 1000) {
const thousands = int_val / 1000;
const remainder = int_val % 1000;
return std.fmt.comptimePrint("{d},{d}", .{ thousands, remainder });
}
return std.fmt.comptimePrint("{d}", .{int_val});
}
pub fn formatLatency(ms: f64) []const u8 {
return std.fmt.comptimePrint("{d:.2} ms", .{ms});
}
pub fn formatBuildTime(s: f64) []const u8 {
const int_s = @as(u32, @intFromFloat(s));
if (int_s >= 60) {
const mins = int_s / 60;
const secs = int_s % 60;
return std.fmt.comptimePrint("{d}m {d}s", .{ mins, secs });
}
return std.fmt.comptimePrint("{d}s", .{int_s});
}
pub fn formatImageSize(mb: f64) []const u8 {
if (mb >= 1000) {
const gb = mb / 1000;
return std.fmt.comptimePrint("{d:.1} GB", .{gb});
}
return std.fmt.comptimePrint("{d} MB", .{@as(u32, @intFromFloat(mb))});
}
pub fn formatColdStart(ms: f64) []const u8 {
return std.fmt.comptimePrint("{d} ms", .{@as(u32, @intFromFloat(ms))});
}
pub fn formatCpuPeak(pct: f64) []const u8 {
return std.fmt.comptimePrint("{d:.1}%", .{pct});
}
pub fn formatBinarySize(mb: f64) []const u8 {
if (mb == 0) return "N/A";
return std.fmt.comptimePrint("{d:.1} MB", .{mb});
}

View file

@ -788,6 +788,37 @@ fn isPreElement(tag: []const u8) bool {
return std.mem.eql(u8, tag, "pre");
}
fn normalizeText(source: []const u8, node_start: u32, node_end: u32) ?[]const u8 {
if (node_start >= node_end or node_end > source.len) return null;
const text = source[node_start..node_end];
if (text.len == 0) return null;
const preceded_by_newline = node_start > 0 and isNewline(source[node_start - 1]);
const followed_by_newline = node_end < source.len and isNewline(source[node_end]);
const trimmed = std.mem.trim(u8, text, &std.ascii.whitespace);
if (trimmed.len == 0) {
if (std.mem.indexOfAny(u8, text, "\n\r") != null or preceded_by_newline) return null;
return text;
}
const content_start = @intFromPtr(trimmed.ptr) - @intFromPtr(text.ptr);
const content_end = content_start + trimmed.len;
const leading = text[0..content_start];
const trailing = text[content_end..];
const strip_leading = preceded_by_newline or std.mem.indexOfAny(u8, leading, "\n\r") != null;
const strip_trailing = followed_by_newline or std.mem.indexOfAny(u8, trailing, "\n\r") != null;
const start: usize = if (strip_leading) content_start else 0;
const end: usize = if (strip_trailing) content_end else text.len;
return text[start..end];
}
fn isNewline(c: u8) bool {
return c == '\n' or c == '\r';
}
/// Escape text for use in Zig string literal
fn escapeZigString(text: []const u8, ctx: *TranspileContext) !void {
for (text) |c| {
@ -1461,11 +1492,10 @@ pub fn transpileChild(self: *Ast, node: ts.Node, ctx: *TranspileContext, preserv
switch (NodeKind.fromNode(child)) {
.zx_text => {
const text = try self.getNodeText(child);
if (preserve_whitespace) {
// For <pre> and similar: preserve whitespace exactly
// Add \n at end of each text node except the last child
const text = try self.getNodeText(child);
if (text.len == 0) continue;
try ctx.writeM("_zx.txt(\"", child.startByte(), self);
@ -1475,19 +1505,10 @@ pub fn transpileChild(self: *Ast, node: ts.Node, ctx: *TranspileContext, preserv
try ctx.write("\")");
had_output = true;
} else {
// Normal mode: trim and normalize whitespace
const trimmed = std.mem.trim(u8, text, &std.ascii.whitespace);
if (trimmed.len == 0) continue;
// JSX-like whitespace handling: preserve leading/trailing single space
// when adjacent to expressions or other inline content
const has_leading_ws = text.len > 0 and std.ascii.isWhitespace(text[0]);
const has_trailing_ws = text.len > 0 and std.ascii.isWhitespace(text[text.len - 1]);
const normalized = normalizeText(self.source, child.startByte(), child.endByte()) orelse continue;
try ctx.writeM("_zx.txt(\"", child.startByte(), self);
if (has_leading_ws) try ctx.write(" ");
try escapeZigString(trimmed, ctx);
if (has_trailing_ws) try ctx.write(" ");
try escapeZigString(normalized, ctx);
try ctx.write("\")");
had_output = true;
}

View file

@ -44,7 +44,12 @@ pub fn data(self: Action, comptime T: type) T {
var result: T = undefined;
const type_struct = @typeInfo(T).@"struct";
inline for (type_struct.field_names, type_struct.field_types) |field_name, field_type| {
@field(result, field_name) = parseFormField(field_type, get(self._internal.entries, field_name));
@field(result, field_name) = parseFormField(
field_type,
self.allocator,
self._internal.entries,
field_name,
);
}
return result;
}
@ -57,6 +62,24 @@ fn get(entries: []const []const u8, name: []const u8) ?[]const u8 {
return null;
}
fn getAll(allocator: Allocator, entries: []const []const u8, name: []const u8) []const []const u8 {
var count: usize = 0;
var i: usize = 0;
while (i + 1 < entries.len) : (i += 2) {
if (std.mem.eql(u8, entries[i], name)) count += 1;
}
const values = allocator.alloc([]const u8, count) catch return &.{};
i = 0;
var out: usize = 0;
while (i + 1 < entries.len) : (i += 2) {
if (!std.mem.eql(u8, entries[i], name)) continue;
values[out] = entries[i + 1];
out += 1;
}
return values;
}
/// Stateful client action - provides `state()` access to bound component state.
/// Use `fn(*zx.client.Action.Stateful) void` with `ctx.bind()` to get this type.
pub const Stateful = struct {
@ -76,9 +99,19 @@ pub const Stateful = struct {
};
// TODO: this is duplicated logic that we can later re-use by isolating to it's own thing and adding tests for it.
fn parseFormField(comptime T: type, raw: ?[]const u8) T {
fn parseFormField(
comptime T: type,
allocator: Allocator,
entries: []const []const u8,
name: []const u8,
) T {
if (comptime T == []const []const u8) return getAll(allocator, entries, name);
return parseScalar(T, get(entries, name));
}
fn parseScalar(comptime T: type, raw: ?[]const u8) T {
switch (@typeInfo(T)) {
.optional => |opt| return parseFormField(opt.child, raw orelse return null),
.optional => |opt| return parseScalar(opt.child, raw orelse return null),
.pointer => {
comptime if (T != []const u8) @compileError("ctx.data(): unsupported pointer type: " ++ @typeName(T));
return raw orelse "";

View file

@ -134,6 +134,14 @@ pub const EventType = enum(u8) {
touchend,
touchmove,
scroll,
wheel,
pointerdown,
pointermove,
pointerup,
pointercancel,
pointerenter,
pointerleave,
lostpointercapture,
/// Parse event type from attribute name (e.g., "onclick" -> .click)
pub fn fromAttributeName(name: []const u8) ?EventType {

View file

@ -42,11 +42,18 @@ pub fn stopImmediatePropagation(self: Event) void {
self.getEvent().stopImmediatePropagation();
}
/// Get the input value from event.target.value
/// Get the input/button value for the element that owns the handler.
/// Prefers `currentTarget` so nested children (e.g. spans inside a button)
/// still resolve the bound element's `value`.
pub fn value(self: Event) ?[]const u8 {
if (platform_role != .client) return null;
const event = self.getEvent();
if (event.ref.get(js.Object, "currentTarget") catch null) |current| {
defer current.deinit();
if (current.getAlloc(js.String, zx.allocator, "value") catch null) |v| return v;
}
const target = event.ref.get(js.Object, "target") catch return null;
defer target.deinit();
return target.getAlloc(js.String, zx.allocator, "value") catch null;
}
@ -59,10 +66,24 @@ pub fn key(self: Event) ?[]const u8 {
/// Get the event data by providing zx.client.events.<Type>.
pub fn as(self: Event, comptime T: type, allocator: Allocator) T {
_ = allocator;
if (platform_role != .client) return std.mem.zeroInit(T, .{});
const event = self.getEvent();
if (comptime (builtin.mode == .Debug)) assertType(T, event);
return readStruct(T, allocator, event.ref);
return readStruct(T, scratchAllocator(), event.ref);
}
var scratch_arena: std.heap.ArenaAllocator = undefined;
var scratch_ready: bool = false;
fn scratchAllocator() Allocator {
if (!scratch_ready) {
scratch_arena = .init(zx.allocator);
scratch_ready = true;
} else {
_ = scratch_arena.reset(.retain_capacity);
}
return scratch_arena.allocator();
}
/// Debug-only guard: validates that requested struct `T` matches the live event.

View file

@ -13,6 +13,17 @@ pub const HTMLElement = struct {
ref: JsObject,
allocator: std.mem.Allocator,
pub const Rect = struct {
x: f64,
y: f64,
width: f64,
height: f64,
top: f64,
right: f64,
bottom: f64,
left: f64,
};
pub fn init(allocator: std.mem.Allocator, ref: JsObject) HTMLElement {
return .{
.ref = ref,
@ -69,6 +80,37 @@ pub const HTMLElement = struct {
return try self.ref.get(T, name);
}
pub fn focus(self: HTMLElement) !void {
if (!is_wasm) return error.NotInBrowser;
try self.ref.call(void, "focus", .{});
}
pub fn getBoundingClientRect(self: HTMLElement) !Rect {
if (!is_wasm) return error.NotInBrowser;
const rect = try self.ref.call(@import("js").Object, "getBoundingClientRect", .{});
defer rect.deinit();
return .{
.x = try rect.get(f64, "x"),
.y = try rect.get(f64, "y"),
.width = try rect.get(f64, "width"),
.height = try rect.get(f64, "height"),
.top = try rect.get(f64, "top"),
.right = try rect.get(f64, "right"),
.bottom = try rect.get(f64, "bottom"),
.left = try rect.get(f64, "left"),
};
}
pub fn setPointerCapture(self: HTMLElement, pointer_id: i32) !void {
if (!is_wasm) return error.NotInBrowser;
try self.ref.call(void, "setPointerCapture", .{pointer_id});
}
pub fn releasePointerCapture(self: HTMLElement, pointer_id: i32) !void {
if (!is_wasm) return error.NotInBrowser;
try self.ref.call(void, "releasePointerCapture", .{pointer_id});
}
pub fn removeChild(self: HTMLElement, child: HTMLNode) !void {
if (!is_wasm) return;
switch (child) {

View file

@ -104,9 +104,24 @@ fn readValue(comptime T: type, allocator: std.mem.Allocator, d: []const u8, p: *
skip(d, p);
var result: T = undefined;
inline for (s.field_names, s.field_types, 0..) |field_name, field_type, i| {
if (i > 0) comma(d, p);
@field(result, field_name) = try readValue(field_type, allocator, d, p);
var filling_defaults = false;
inline for (s.field_names, s.field_types, s.field_attrs, 0..) |field_name, field_type, field_attr, i| {
skip(d, p);
if (filling_defaults or peek(d, p) == ']') {
filling_defaults = true;
if (field_attr.defaultValue(field_type)) |default_value| {
@field(result, field_name) = default_value;
} else {
return error.MissingRequiredField;
}
} else {
if (i > 0) {
if (peek(d, p) != ',') return error.ExpectedComma;
p.* += 1;
skip(d, p);
}
@field(result, field_name) = try readValue(field_type, allocator, d, p);
}
}
skip(d, p);

View file

@ -258,6 +258,10 @@ test "element_fragment_root" {
try test_transpile("element/fragment_root");
try test_render("element/fragment_root", @import("./../data/element/fragment_root.zig").Page);
}
test "element_whitespace" {
try test_transpile("element/whitespace");
try test_render("element/whitespace", @import("./../data/element/whitespace.zig").Page);
}
test "escaping_pre" {
try test_transpile("escaping/pre");
@ -565,6 +569,7 @@ fn getPageFn(comptime path: []const u8) ?fn (std.mem.Allocator) zx.Component {
.{ "element/nested", @import("./../data/element/nested.zig") },
.{ "element/fragment", @import("./../data/element/fragment.zig") },
.{ "element/fragment_root", @import("./../data/element/fragment_root.zig") },
.{ "element/whitespace", @import("./../data/element/whitespace.zig") },
.{ "escaping/pre", @import("./../data/escaping/pre.zig") },
.{ "escaping/quotes", @import("./../data/escaping/quotes.zig") },
.{ "control_flow/if_while_if", @import("./../data/control_flow/if_while_if.zig") },

View file

@ -697,7 +697,41 @@ test "parse empty string input" {
test "parse truncated input" {
const P = struct { a: i32, b: i32 };
// Missing closing bracket and second field
try testing.expectError(error.ExpectedArrayEnd, zxon.parse(P, testing.allocator, "[42", .{}));
try testing.expectError(error.ExpectedComma, zxon.parse(P, testing.allocator, "[42", .{}));
}
test "parse omits trailing default fields" {
const Link = struct { label: []const u8, href: []const u8 };
const User = struct {
username: []const u8,
places: []const struct { lat: f64, lng: f64 },
links: []const Link = &.{},
};
const r = try zxon.parse(User, testing.allocator, "[\"miktwon\",[[50.4501,30.5234]]]", .{});
defer {
testing.allocator.free(r.username);
testing.allocator.free(r.places);
}
try testing.expectEqualStrings("miktwon", r.username);
try testing.expectEqual(@as(usize, 1), r.places.len);
try testing.expectEqual(@as(usize, 0), r.links.len);
}
test "parse omits multiple trailing defaults" {
const P = struct {
required: i32,
opt_a: bool = false,
opt_b: []const u8 = "",
};
const r = try zxon.parse(P, testing.allocator, "[7]", .{});
try testing.expectEqual(@as(i32, 7), r.required);
try testing.expect(!r.opt_a);
try testing.expectEqualStrings("", r.opt_b);
}
test "parse missing required field when array ends early" {
const P = struct { a: i32, b: i32 };
try testing.expectError(error.MissingRequiredField, zxon.parse(P, testing.allocator, "[42]", .{}));
}
test "parse empty struct" {

View file

@ -1 +1 @@
<main><div class="container" id="main-content"><button class="active"> Click me</button></div></main>
<main><div class="container" id="main-content"><button class="active">Click me</button></div></main>

View file

@ -72,7 +72,7 @@
10:16 -> 20:35 | "</button>" => ","
8:24 -> 23:54 | "class={if (is_active" => "class", if (is_activ"
8:30 -> 23:62 | "{if (is_active) "act" => "if (is_active) "acti"
9:0 -> 26:36 | " " => "_zx.txt(" Click me")"
9:0 -> 26:36 | " " => "_zx.txt("Click me"),"
10:25 -> 29:24 | "" => "),"
11:18 -> 32:12 | "" => "),"
12:15 -> 35:0 | "" => ");"

View file

@ -24,7 +24,7 @@ pub fn Page(allocator: zx.Allocator) zx.Component {
_zx.attr(@src(), "class", if (is_active) "active" else "inactive"),
}),
.children = &.{
_zx.txt(" Click me"),
_zx.txt("Click me"),
},
},
),

View file

@ -1 +1 @@
<button> Click me</button>
<button>Click me</button>

View file

@ -12,7 +12,7 @@ pub fn Page(allocator: zx.Allocator) zx.Component {
_zx.attr(@src(), "onclick", handleClick),
}),
.children = &.{
_zx.txt(" Click me"),
_zx.txt("Click me"),
},
},
),

View file

@ -1 +1 @@
<main><button>Submit</button><button>Cancel</button><span>Score #1</span><span>Points #2</span><span>Rating #3</span></main>
<main><button>Submit</button><button>Cancel</button><span>Score#1</span><span>Points#2</span><span>Rating#3</span></main>

View file

@ -63,7 +63,7 @@ fn AsyncScore(allocator: zx.Allocator, props: AsyncScoreProps) zx.Component {
.allocator = allocator,
.children = &.{
_zx.expr(props.label),
_zx.txt(" #"),
_zx.txt("#"),
_zx.expr(props.index),
},
},

View file

@ -1 +1 @@
<main><p><p><div>John</div></p></p><p><p><div>Jane</div></p></p></main>
<main><p><p><div>John</div></p> </p><p><p><div>Jane</div></p> </p></main>

View file

@ -31,6 +31,7 @@ pub fn Page(allocator: zx.Allocator) zx.Component {
},
},
),
_zx.txt(" "),
},
},
);

1
test/data/element/whitespace.html generated Normal file
View file

@ -0,0 +1 @@
<div><span>| </span><span> |</span><span> | </span><span> | </span><span>|</span><span>| </span><span>|</span><p>hello world</p><p> hello </p><p>hello world</p><a>left</a> <a>right</a><b>no</b><b>space</b></div>

View file

@ -0,0 +1,126 @@
pub fn Page(allocator: zx.Allocator) zx.Component {
var _zx = @import("zx").x.allocInit(allocator, .{ .src = @src() });
return _zx.ele(
.div,
.{
.allocator = allocator,
.children = &.{
_zx.ele(
.span,
.{
.children = &.{
_zx.txt("| "),
},
},
),
_zx.ele(
.span,
.{
.children = &.{
_zx.txt(" |"),
},
},
),
_zx.ele(
.span,
.{
.children = &.{
_zx.txt(" | "),
},
},
),
_zx.ele(
.span,
.{
.children = &.{
_zx.txt(" | "),
},
},
),
_zx.ele(
.span,
.{
.children = &.{
_zx.txt("|"),
},
},
),
_zx.ele(
.span,
.{
.children = &.{
_zx.txt("| "),
},
},
),
_zx.ele(
.span,
.{
.children = &.{
_zx.txt("|"),
},
},
),
_zx.ele(
.p,
.{
.children = &.{
_zx.txt("hello world"),
},
},
),
_zx.ele(
.p,
.{
.children = &.{
_zx.txt(" hello "),
},
},
),
_zx.ele(
.p,
.{
.children = &.{
_zx.txt("hello world"),
},
},
),
_zx.ele(
.a,
.{
.children = &.{
_zx.txt("left"),
},
},
),
_zx.txt(" "),
_zx.ele(
.a,
.{
.children = &.{
_zx.txt("right"),
},
},
),
_zx.ele(
.b,
.{
.children = &.{
_zx.txt("no"),
},
},
),
_zx.ele(
.b,
.{
.children = &.{
_zx.txt("space"),
},
},
),
},
},
);
}
const zx = @import("zx");

View file

@ -0,0 +1,27 @@
pub fn Page(allocator: zx.Allocator) zx.Component {
return (
<div @allocator={allocator}>
<span>| </span>
<span> |</span>
<span> | </span>
<span> | </span>
<span>
|
</span>
<span>
| </span>
<span>|
</span>
<p>
hello world
</p>
<p> hello </p>
<p>hello world</p>
<a>left</a> <a>right</a>
<b>no</b>
<b>space</b>
</div>
);
}
const zx = @import("zx");

View file

@ -1 +1 @@
<div> quote should be escaped <code>"quote"</code><pre>"quote"</pre></div>
<div>quote should be escaped<code>"quote"</code><pre>"quote"</pre></div>

View file

@ -5,7 +5,7 @@ pub fn Page(allocator: zx.Allocator) zx.Component {
.{
.allocator = allocator,
.children = &.{
_zx.txt(" quote should be escaped "),
_zx.txt("quote should be escaped"),
_zx.ele(
.code,
.{

View file

@ -1 +1 @@
<main><p>Hello Alice, you have 5apples in your cart.</p><p>Welcome back, Alice! Your order #5 is ready.</p><span>Item: apple (qty: 5)</span></main>
<main><p>Hello Alice, you have 5 apples in your cart.</p><p>Welcome back, Alice! Your order #5 is ready.</p><span>Item: apple (qty: 5)</span></main>

View file

@ -17,6 +17,7 @@ pub fn Page(allocator: zx.Allocator) zx.Component {
_zx.expr(name),
_zx.txt(", you have "),
_zx.expr(count),
_zx.txt(" "),
_zx.expr(item),
_zx.txt("s in your cart."),
},

View file

@ -10,14 +10,14 @@ pub fn Collection(allocator: zx.Allocator, props: anytype) zx.Component {
.fragment,
.{
.children = &.{
_zx.txt(" No cards found with '"),
_zx.txt("No cards found with '"),
_zx.expr(props.name),
_zx.txt("' in their name"),
_zx.ele(
.br,
.{},
),
_zx.txt(" HINT: Try "),
_zx.txt("HINT: Try "),
_zx.ele(
.a,
.{
@ -37,12 +37,12 @@ pub fn Collection(allocator: zx.Allocator, props: anytype) zx.Component {
.children = &.{
_zx.expr(props.name),
_zx.expr(' '),
_zx.txt(" in their name"),
_zx.txt("in their name"),
_zx.ele(
.br,
.{},
),
_zx.txt(" HINT: Try"),
_zx.txt("HINT: Try"),
_zx.ele(
.a,
.{

View file

@ -10,14 +10,14 @@ pub fn Collection(allocator: zx.Allocator, props: anytype) zx.Component {
.fragment,
.{
.children = &.{
_zx.txt(" No cards found with '"),
_zx.txt("No cards found with '"),
_zx.expr(props.name),
_zx.txt("' in their name"),
_zx.ele(
.br,
.{},
),
_zx.txt(" HINT: Try "),
_zx.txt("HINT: Try "),
_zx.ele(
.a,
.{
@ -37,12 +37,12 @@ pub fn Collection(allocator: zx.Allocator, props: anytype) zx.Component {
.children = &.{
_zx.expr(props.name),
_zx.expr(' '),
_zx.txt(" in their name"),
_zx.txt("in their name"),
_zx.ele(
.br,
.{},
),
_zx.txt(" HINT: Try"),
_zx.txt("HINT: Try"),
_zx.ele(
.a,
.{

View file

@ -56,7 +56,31 @@ pub fn FmtWhitespace(allocator: zx.Allocator) zx.Component {
.span,
.{
.children = &.{
_zx.txt(" |"),
_zx.txt("|"),
},
},
),
_zx.ele(
.span,
.{
.children = &.{
_zx.txt(" | "),
},
},
),
_zx.ele(
.p,
.{
.children = &.{
_zx.txt("hello"),
},
},
),
_zx.ele(
.p,
.{
.children = &.{
_zx.txt(" hello "),
},
},
),

View file

@ -10,6 +10,11 @@ pub fn FmtWhitespace(allocator: zx.Allocator) zx.Component {
<span>
|
</span>
<span> | </span>
<p>
hello
</p>
<p> hello </p>
</div>
);
}

View file

@ -56,7 +56,31 @@ pub fn FmtWhitespace(allocator: zx.Allocator) zx.Component {
.span,
.{
.children = &.{
_zx.txt(" |"),
_zx.txt("|"),
},
},
),
_zx.ele(
.span,
.{
.children = &.{
_zx.txt(" | "),
},
},
),
_zx.ele(
.p,
.{
.children = &.{
_zx.txt("hello"),
},
},
),
_zx.ele(
.p,
.{
.children = &.{
_zx.txt(" hello "),
},
},
),

View file

@ -10,6 +10,11 @@ pub fn FmtWhitespace(allocator: zx.Allocator) zx.Component {
<span>
|
</span>
<span> | </span>
<p>
hello
</p>
<p> hello </p>
</div>
);
}

View file

@ -12,7 +12,7 @@ pub fn Page(allocator: zx.Allocator) zx.Component {
_zx.attr(@src(), "style", style),
}),
.children = &.{
_zx.txt(" Hello"),
_zx.txt("Hello"),
},
},
);

View file

@ -14,7 +14,7 @@ pub fn Page(allocator: zx.Allocator) zx.Component {
_zx.attr(@src(), "style", style),
}),
.children = &.{
_zx.txt(" Hello"),
_zx.txt("Hello"),
},
},
);

View file

@ -8,7 +8,7 @@ pub fn Page(allocator: zx.Allocator) zx.Component {
_zx.attr(@src(), "style", .{ .display = .flex, .padding_top = .px(10), .width = .px(100) }),
}),
.children = &.{
_zx.txt(" Hello"),
_zx.txt("Hello"),
},
},
);

View file

@ -9,18 +9,16 @@
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"@playwright/test": "1.61.0",
"@types/node": "^25.5.0"
"@playwright/test": "1.61.1",
"@types/node": "^26.1.1"
}
},
"node_modules/@playwright/test": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz",
"integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==",
"version": "1.61.1",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.61.0"
"playwright": "1.61.1"
},
"bin": {
"playwright": "cli.js"
@ -30,21 +28,16 @@
}
},
"node_modules/@types/node": {
"version": "25.9.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz",
"integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==",
"version": "26.1.1",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
"undici-types": "~8.3.0"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
@ -55,13 +48,11 @@
}
},
"node_modules/playwright": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
"version": "1.61.1",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.0"
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
@ -74,9 +65,7 @@
}
},
"node_modules/playwright-core": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
"version": "1.61.1",
"dev": true,
"license": "Apache-2.0",
"bin": {
@ -87,9 +76,7 @@
}
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"version": "8.3.0",
"dev": true,
"license": "MIT"
}

View file

@ -9,7 +9,7 @@
"license": "ISC",
"type": "commonjs",
"devDependencies": {
"@playwright/test": "1.61.0",
"@types/node": "^25.5.0"
"@playwright/test": "1.61.1",
"@types/node": "^26.1.1"
}
}

View file

@ -18,7 +18,7 @@ export default defineConfig({
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
retries: process.env.CI ? 2 : 1,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
@ -32,21 +32,21 @@ export default defineConfig({
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
// projects: [
// {
// name: 'chromium',
// use: { ...devices['Desktop Chrome'] },
// },
// {
// name: 'firefox',
// use: { ...devices['Desktop Firefox'] },
// },
// {
// name: 'firefox',
// use: { ...devices['Desktop Firefox'] },
// },
// {
// name: 'webkit',
// use: { ...devices['Desktop Safari'] },
// },
// {
// name: 'webkit',
// use: { ...devices['Desktop Safari'] },
// },
/* Test against mobile viewports. */
// {
@ -67,7 +67,7 @@ export default defineConfig({
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
// ],
...(!process.env.BASE_URL && {
webServer: {

View file

@ -91,6 +91,7 @@ pub const TestFileCache = struct {
"element/nested",
"element/fragment",
"element/fragment_root",
"element/whitespace",
// Expression
"expression/optional",

View file

@ -72,6 +72,10 @@ export class ZigJS {
* */
protected valueGet(out: number, id: number, ptr: number, len: number): void {
const val = this.loadValue(id);
if (val === null || val === undefined || (typeof val !== 'object' && typeof val !== 'function')) {
this.storeValue(out, undefined);
return;
}
const str = this.loadString(ptr, len);
const result = Reflect.get(val, str);
this.storeValue(out, result);
@ -129,11 +133,15 @@ export class ZigJS {
protected valueStringCopy(id: number, ptr: number, max: number): void {
if (this.memory == null) return;
const dest = ptr >>> 0;
const cap = max >>> 0;
const val = this.loadValue(id);
if (typeof val !== "string") return;
const bytes = encoder.encode(val);
if (bytes.byteLength > max) return;
if (bytes.length === 0) return;
new Uint8Array(this.memory.buffer, ptr, bytes.length).set(bytes);
if (bytes.byteLength > cap || bytes.length === 0) return;
const buf = this.memory.buffer;
if (dest + bytes.length > buf.byteLength) return;
new Uint8Array(buf, dest, bytes.length).set(bytes);
}
/**
@ -142,8 +150,10 @@ export class ZigJS {
protected valueNew(out: number, id: number, argsAddr: number, argsLen: number): void {
const fn = this.loadValue(id);
const args = [];
for (let i = 0; i < argsLen; i++) {
args.push(this.loadRef(argsAddr + (i * 8)));
const base = argsAddr >>> 0;
const count = argsLen >>> 0;
for (let i = 0; i < count; i++) {
args.push(this.loadRef(base + (i * 8)));
}
const result = Reflect.construct(fn, args);
@ -155,14 +165,25 @@ export class ZigJS {
* */
protected funcApply(out: number, id: number, thisRefAddr: number, argsAddr: number, argsLen: number): void {
const fn = this.loadValue(id);
const thisVal = this.loadRef(thisRefAddr);
const thisVal = this.loadRef(thisRefAddr >>> 0);
const args = [];
for (let i = 0; i < argsLen; i++) {
args.push(this.loadRef(argsAddr + (i * 8)));
const base = argsAddr >>> 0;
const count = argsLen >>> 0;
for (let i = 0; i < count; i++) {
args.push(this.loadRef(base + (i * 8)));
}
const result = Reflect.apply(fn, thisVal, args);
this.storeValue(out, result);
if (typeof fn !== 'function') {
this.storeValue(out, undefined);
return;
}
try {
const result = Reflect.apply(fn, thisVal, args);
this.storeValue(out, result);
} catch {
// Native DOM methods throw Illegal invocation when `this` is wrong/stale.
this.storeValue(out, undefined);
}
}
/**
@ -186,25 +207,27 @@ export class ZigJS {
loadRef(refAddr: number): any {
if (this.memory == null) return;
const addr = refAddr >>> 0;
const view = new DataView(this.memory.buffer);
// If the value at the memory location is not a NaN, return it directly.
const floatVal = view.getFloat64(refAddr, true);
const floatVal = view.getFloat64(addr, true);
if (!isNaN(floatVal)) return floatVal;
// If it is a NaN, we need to get the ID.
const id = this.loadRefId(refAddr);
const id = this.loadRefId(addr);
return this.values[id];
}
loadRefId(refAddr: number): number {
if (this.memory == null) return 0;
return new DataView(this.memory.buffer).getUint32(refAddr, true);
return new DataView(this.memory.buffer).getUint32(refAddr >>> 0, true);
}
storeValue(out: number, val: any): void {
if (this.memory == null) return;
const view = new DataView(this.memory.buffer);
out = out >>> 0;
if (typeof val === "number") {
// We have to turn NaNs into a single value (since NaN can be
@ -274,10 +297,16 @@ export class ZigJS {
loadString(ptr: number, len: number): string {
if (this.memory == null) return "";
// WASM i32 args are signed in JS; pointers/lengths must be treated as u32.
const start = ptr >>> 0;
const length = Number(len) >>> 0;
const buf = this.memory.buffer;
if (length === 0 || start + length > buf.byteLength) return "";
// We slice a clamped array instead of using a DataView so that
// we can also support SharedArrayBuffers. It would probably be slightly
// more performant (maybe?) to check and use either.
const arr = new Uint8ClampedArray(this.memory.buffer, ptr, Number(len));
const arr = new Uint8ClampedArray(buf, start, length);
const data = arr.slice();
return decoder.decode(data);
}

View file

@ -146,17 +146,19 @@ pub const Value = enum(u64) {
/// Set the value of a property on an object.
pub fn set(self: Value, n: []const u8, v: Value) !void {
if (self.typeOf() != .object) return js.Error.InvalidType;
ext.valueSet(self.ref().id, n.ptr, n.len, &@as(u64, @bitCast(v.ref())));
var value_ref: u64 = @bitCast(v.ref());
ext.valueSet(self.ref().id, n.ptr, n.len, &value_ref);
}
/// Call this value as a function.
pub fn apply(self: Value, this: Value, args: []Value) !Value {
if (self.typeOf() != .function) return js.Error.InvalidType;
var result: u64 = undefined;
var this_ref: u64 = @bitCast(this.ref());
ext.funcApply(
&result,
self.ref().id,
&@as(u64, @bitCast(this.ref())),
&this_ref,
@ptrCast(args.ptr),
args.len,
);