fix: action handler states bounding

This commit is contained in:
Nurul Huda (Apon) 2026-03-22 23:40:24 +06:00
parent b1e17379d6
commit 5f8690ab06
No known key found for this signature in database
GPG Key ID: 5D3F1DE2855A2F79
7 changed files with 212 additions and 86 deletions

View File

@ -19,8 +19,9 @@ pub fn LoginForm(ctx: *zx.ComponentContext) zx.Component {
}
const LoginInput = struct { name: []const u8, id: i32 };
fn handleSubmit(data: LoginInput, sc: *zx.StateContext) void {
const msg = sc.state([]const u8);
fn handleSubmit(ctx: *zx.server.Action.Stateful) void {
const data = ctx.data(LoginInput);
const msg = ctx.state([]const u8);
zx.log.info("\n\nName: {s}\nID: {d}\nMessage: {s}\n", .{ data.name, data.id, msg.get()});
@ -29,7 +30,7 @@ fn handleSubmit(data: LoginInput, sc: *zx.StateContext) void {
return;
}
msg.set(sc.fmt("Hello, {s}! ({d})", .{ data.name, data.id }) catch "Err");
msg.set(ctx.fmt("Hello, {s}! ({d})", .{ data.name, data.id }) catch "Err");
zx.log.info("\n\nName: {s}\nID: {d}\n", .{ data.name, data.id });
}

View File

@ -10,7 +10,7 @@ pub fn Page(ctx: zx.PageContext) zx.Component {
pub fn LoginForm(ctx: *zx.ComponentContext) zx.Component {
const msg = ctx.state([]const u8, "Please log in");
return (
<form @allocator={ctx.allocator} action={handleLogin}>
<form @allocator={ctx.allocator} action={ctx.bind(handleSubmit)}>
<p>{msg}</p>
<input placeholder="Enter name" name="name" defaultValue="John Doe" required />
<input placeholder="Enter ID" name="id" defaultValue="123" required />

View File

@ -8,6 +8,7 @@ const Response = @import("runtime/core/Response.zig");
const CoreEvent = @import("runtime/core/Event.zig");
const ClientEvent = @import("runtime/client/Event.zig");
const ServerEvent = @import("runtime/server/Event.zig");
const ActionContext = @import("runtime/server/Action.zig");
const StateContext = CoreEvent.StateContext;
const Allocator = std.mem.Allocator;
@ -49,70 +50,6 @@ pub const ProxyContext = struct {
}
};
/// Context for server-side form action handlers.
pub const ActionContext = struct {
request: Request = undefined,
response: Response = undefined,
allocator: Allocator = undefined,
arena: Allocator = undefined,
action_ref: u64 = 0,
_state_ctx: ?*StateContext = null,
pub fn init(action_ref: u64) ActionContext {
return .{ .action_ref = action_ref };
}
pub fn data(self: ActionContext, comptime T: type) T {
comptime if (@typeInfo(T) != .@"struct") @compileError("ctx.data() requires a struct type, got: " ++ @typeName(T));
const content_type = self.request.headers.get("content-type") orelse "";
var result: T = undefined;
if (std.mem.indexOf(u8, content_type, "multipart/form-data") != null) {
const mfd = self.request.multiFormData();
inline for (@typeInfo(T).@"struct".fields) |field| {
if (comptime field.type == zx.File) {
const val = mfd.get(field.name);
@field(result, field.name) = if (val) |v| zx.File.fromBytes(v.data, v.filename orelse "", "", self.arena) else zx.File{};
} else if (comptime field.type == ?zx.File) {
const val = mfd.get(field.name);
@field(result, field.name) = if (val) |v| zx.File.fromBytes(v.data, v.filename orelse "", "", self.arena) else null;
} else {
@field(result, field.name) = parseFormField(field.type, mfd.getValue(field.name), self.arena);
}
}
} else {
const fd = self.request.formData();
inline for (@typeInfo(T).@"struct".fields) |field| {
if (comptime field.type == zx.File or field.type == ?zx.File) {
@field(result, field.name) = if (comptime field.type == zx.File) zx.File{} else null;
} else {
@field(result, field.name) = parseFormField(field.type, fd.get(field.name), self.arena);
}
}
}
return result;
}
};
fn parseFormField(comptime T: type, raw: ?[]const u8, allocator: Allocator) T {
_ = allocator;
switch (@typeInfo(T)) {
.optional => |opt| return parseFormField(opt.child, raw orelse return null, undefined),
.pointer => {
comptime if (T != []const u8) @compileError("ctx.data(): unsupported pointer type: " ++ @typeName(T));
return raw orelse "";
},
.bool => {
const val = raw orelse return false;
return std.mem.eql(u8, val, "true") or std.mem.eql(u8, val, "1") or std.mem.eql(u8, val, "on");
},
.int => return std.fmt.parseInt(T, raw orelse return 0, 10) catch 0,
.float => return std.fmt.parseFloat(T, raw orelse return 0) catch 0,
else => @compileError("ctx.data(): unsupported field type '" ++ @typeName(T) ++ "'"),
}
}
// ----- ComponentCtx ----- //
const BindSignMsg =
@ -122,6 +59,8 @@ const BindSignMsg =
\\ - fn(*zx.client.Event) void
\\ - fn(*zx.server.Event.Stateful) void
\\ - fn(*zx.server.Event) void
\\ - fn(*zx.server.Action.Stateful) void
\\ - fn(*zx.server.Action) void
\\ - fn(ActionContext, *StateContext) void
\\ - fn(struct, *StateContext) void
\\
@ -184,6 +123,7 @@ pub fn ComponentCtx(comptime PropsType: type) type {
fn (*ServerEvent) void => zx.EventHandler.server(handler, alloc, &self._handler_index),
// Server Actions
fn (*ActionContext.Stateful) void => zx.EventHandler.actionStateful(handler, alloc, self._component_id, self._state_index, &self._handler_index),
fn (ActionContext, *StateContext) void => actionBind(handler, alloc, self),
fn (*ActionContext) void => actionBind(handler, alloc, self),
@ -214,10 +154,8 @@ fn actionBind(comptime handler: anytype, alloc: Allocator, ctx: anytype) zx.Even
{
const FormActionWrapper = struct {
fn wrap(action_ctx_ptr: *ActionContext) void {
const mfd = action_ctx_ptr.request.multiFormData();
const states_raw = mfd.getValue("__$states") orelse "[]";
const states = zx.util.zxon.parse([]const []const u8, action_ctx_ptr.arena, states_raw, .{}) catch return;
const sc = StateContext.init(action_ctx_ptr.arena, action_ctx_ptr.arena, states) orelse return;
const inputs = action_ctx_ptr._inputs orelse &.{};
const sc = StateContext.init(action_ctx_ptr.arena, action_ctx_ptr.arena, inputs) orelse return;
action_ctx_ptr._state_ctx = sc;
if (comptime arg0 == ActionContext) {
handler(action_ctx_ptr.*, sc);
@ -226,11 +164,17 @@ fn actionBind(comptime handler: anytype, alloc: Allocator, ctx: anytype) zx.Even
}
}
};
const bound = reactivity.collectStateBoundEntries(alloc, ctx._component_id, ctx._state_index);
ctx._handler_index += 1;
const h_id = ctx._handler_index;
const ec = alloc.create(zx.EventHandler.Context) catch @panic("OOM");
ec.* = .{ .handler_id = h_id, .bound_states = bound };
return zx.EventHandler{
.callback = &zx.EventHandler.actionHandler,
.context = @as(*anyopaque, @ptrFromInt(1)),
.context = @ptrCast(ec),
.action_fn = &FormActionWrapper.wrap,
.bound_states = reactivity.collectStateBoundEntries(alloc, ctx._component_id, ctx._state_index),
.handler_id = h_id,
.bound_states = bound,
};
}
}

View File

@ -229,6 +229,36 @@ pub fn serverSS(
return finalizeServer(alloc, handler_index, &wrap_fn, bound_states);
}
/// Stateful action handler: fn(*zx.server.Action.Stateful) void (auto-binds states from component)
pub fn actionStateful(
comptime handler: anytype,
alloc: Allocator,
component_id: []const u8,
state_index: u32,
handler_index: *u32,
) Self {
const ActionWrap = struct {
fn wrap(ctx: *zx.server.Action) void {
const sc = zx.StateContext.init(ctx.allocator, ctx.arena, ctx._inputs orelse &.{}) orelse return;
ctx._state_ctx = sc;
var sf = zx.server.Action.Stateful{ ._inner = ctx, ._state_ctx = sc };
handler(&sf);
}
};
const bound = reactivity.collectStateBoundEntries(alloc, component_id, state_index);
handler_index.* += 1;
const h_id = handler_index.*;
const ec = alloc.create(Context) catch @panic("OOM");
ec.* = .{ .handler_id = h_id, .bound_states = bound };
return .{
.callback = &actionHandler,
.context = @ptrCast(ec),
.action_fn = &ActionWrap.wrap,
.handler_id = h_id,
.bound_states = bound,
};
}
/// Build Bound vtable for explicitly listed states.
pub fn buildStates(alloc: Allocator, states: anytype) []const Bound {
const state_fields = @typeInfo(@TypeOf(states)).@"struct".fields;
@ -307,27 +337,55 @@ pub fn init(
}
pub fn actionHandler(ctx: *anyopaque, event: zx.client.Event) void {
_ = ctx;
if (!is_wasm) return;
event.preventDefault();
const client_fetch = @import("../client/fetch.zig");
const CoreFetch = @import("Fetch.zig");
const bound_states: []const Bound = if (@intFromPtr(ctx) == 1)
&.{}
else blk: {
const ec: *Context = @ptrCast(@alignCast(ctx));
break :blk ec.bound_states;
};
const headers = [_]CoreFetch.RequestInit.Header{
.{ .name = "Content-Type", .value = "application/json" },
.{ .name = "X-ZX-Action", .value = "1" },
};
client_fetch.fetchAsync(
getGlobalAllocator(),
"",
.{
.method = .GET,
.headers = &headers,
.body = "{}",
},
onActionResponse,
);
if (bound_states.len > 0) {
var state_jsons = std.ArrayList([]const u8).empty;
for (bound_states) |bs| {
const json = bs.getJson(getGlobalAllocator(), bs.state_ptr);
state_jsons.append(getGlobalAllocator(), json) catch {};
}
var aw = std.Io.Writer.Allocating.init(getGlobalAllocator());
zx.util.zxon.serialize(state_jsons.items, &aw.writer, .{}) catch {};
const payload_buf = aw.written();
const cb_ctx = getGlobalAllocator().create(Context) catch return;
cb_ctx.* = .{ .bound_states = bound_states };
client_fetch.fetchAsyncCtx(
getGlobalAllocator(),
"",
.{ .method = .POST, .headers = &headers, .body = payload_buf },
@ptrCast(cb_ctx),
onEventResponse,
);
} else {
client_fetch.fetchAsync(
getGlobalAllocator(),
"",
.{
.method = .GET,
.headers = &headers,
.body = "{}",
},
onActionResponse,
);
}
}
fn eventHandler(ctx: *anyopaque, event: zx.client.Event) void {

View File

@ -1,7 +1,7 @@
const server = @import("server/Server.zig");
pub const Event = @import("server/Event.zig");
pub const Action = @import("../contexts.zig").ActionContext;
pub const Action = @import("server/Action.zig");
// Legacy --- will be renamed
pub const SerilizableAppMeta = server.SerilizableAppMeta;

View File

@ -0,0 +1,106 @@
//! Server-side Action context for server form action handlers.
//!
//! Provides form data parsing via `data()`.
//! For state access, use `Action.Stateful` via `ctx.bind()` or `fn(*zx.server.Action.Stateful) void`.
const std = @import("std");
const zx = @import("../../root.zig");
const Request = @import("../core/Request.zig");
const Response = @import("../core/Response.zig");
const CoreEvent = @import("../core/Event.zig");
const StateContext = CoreEvent.StateContext;
const Allocator = std.mem.Allocator;
const Action = @This();
request: Request = undefined,
response: Response = undefined,
allocator: Allocator = undefined,
arena: Allocator = undefined,
action_ref: u64 = 0,
_state_ctx: ?*StateContext = null,
/// Populated by dispatch when a stateful action sends state values in the request body.
_inputs: ?[]const []const u8 = null,
pub fn init(action_ref: u64) Action {
return .{ .action_ref = action_ref };
}
pub fn data(self: Action, comptime T: type) T {
comptime if (@typeInfo(T) != .@"struct") @compileError("ctx.data() requires a struct type, got: " ++ @typeName(T));
const content_type = self.request.headers.get("content-type") orelse "";
var result: T = undefined;
if (std.mem.indexOf(u8, content_type, "multipart/form-data") != null) {
const mfd = self.request.multiFormData();
inline for (@typeInfo(T).@"struct".fields) |field| {
if (comptime field.type == zx.File) {
const val = mfd.get(field.name);
@field(result, field.name) = if (val) |v| zx.File.fromBytes(v.data, v.filename orelse "", "", self.arena) else zx.File{};
} else if (comptime field.type == ?zx.File) {
const val = mfd.get(field.name);
@field(result, field.name) = if (val) |v| zx.File.fromBytes(v.data, v.filename orelse "", "", self.arena) else null;
} else {
@field(result, field.name) = parseFormField(field.type, mfd.getValue(field.name), self.arena);
}
}
} else {
const fd = self.request.formData();
inline for (@typeInfo(T).@"struct".fields) |field| {
if (comptime field.type == zx.File or field.type == ?zx.File) {
@field(result, field.name) = if (comptime field.type == zx.File) zx.File{} else null;
} else {
@field(result, field.name) = parseFormField(field.type, fd.get(field.name), self.arena);
}
}
}
return result;
}
/// Stateful server action provides `state()` access to bound component state.
/// Use `fn(*zx.server.Action.Stateful) void` with `ctx.bind()` to get this type.
pub const Stateful = struct {
_inner: *Action,
_state_ctx: *StateContext,
/// Access the component's state (server-side).
/// Must be called in the same order as `ctx.state()` in the render function.
pub fn state(self: *Stateful, comptime T: type) CoreEvent.StateHandle(T) {
return self._state_ctx.state(T);
}
/// Parse form data from the action request into struct type T.
pub fn data(self: *Stateful, comptime T: type) T {
return self._inner.data(T);
}
pub fn fmt(self: Stateful, comptime format: []const u8, args: anytype) ![]u8 {
var aw: std.Io.Writer.Allocating = .init(self._state_ctx.arena);
defer aw.deinit();
aw.writer.print(format, args) catch |err| switch (err) {
error.WriteFailed => return error.OutOfMemory,
};
return aw.toOwnedSlice();
}
};
fn parseFormField(comptime T: type, raw: ?[]const u8, allocator: Allocator) T {
_ = allocator;
switch (@typeInfo(T)) {
.optional => |opt| return parseFormField(opt.child, raw orelse return null, undefined),
.pointer => {
comptime if (T != []const u8) @compileError("ctx.data(): unsupported pointer type: " ++ @typeName(T));
return raw orelse "";
},
.bool => {
const val = raw orelse return false;
return std.mem.eql(u8, val, "true") or std.mem.eql(u8, val, "1") or std.mem.eql(u8, val, "on");
},
.int => return std.fmt.parseInt(T, raw orelse return 0, 10) catch 0,
.float => return std.fmt.parseFloat(T, raw orelse return 0) catch 0,
else => @compileError("ctx.data(): unsupported field type '" ++ @typeName(T) ++ "'"),
}
}

View File

@ -70,12 +70,28 @@ pub fn dispatchAction(
const is_js = request.headers.has("x-zx-action");
const sr = request.multiFormData().getValue("__$states") orelse "null";
std.debug.print("IsJS: {}\nStates: {s}", .{ is_js, sr });
// Parse state inputs for stateful actions.
// JS path (X-ZX-Action header): states are the entire JSON body.
// Form path (_submitFormActionAsync): states are in the __$states multipart field.
const action_inputs: ?[]const []const u8 = if (false) blk: {
const body_text = request.text() orelse break :blk null;
break :blk zx.util.zxon.parse([]const []const u8, arena, body_text, .{}) catch null;
} else blk: {
const states_raw = request.multiFormData().getValue("__$states") orelse break :blk null;
break :blk zx.util.zxon.parse([]const []const u8, arena, states_raw, .{}) catch null;
};
if (registry.get(route_path)) |action_fn| {
var action_ctx = zx.server.Action{
.request = request,
.response = response,
.allocator = allocator,
.arena = arena,
._inputs = action_inputs,
};
action_fn(&action_ctx);
const body = if (action_ctx._state_ctx) |sc| try serializeStateOutputs(sc, allocator) else null;
@ -95,6 +111,7 @@ pub fn dispatchAction(
.response = response,
.allocator = allocator,
.arena = arena,
._inputs = action_inputs,
};
action_fn(&action_ctx);
const body = if (action_ctx._state_ctx) |sc| try serializeStateOutputs(sc, allocator) else null;