Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 105 additions & 5 deletions tsunami/frontend/src/model/tsunami-model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import debug from "debug";
import * as jotai from "jotai";

import { arrayBufferToBase64 } from "@/util/base64";
import { getOrCreateClientId } from "@/util/clientid";
import { adaptFromReactOrNativeKeyEvent } from "@/util/keyutil";
import { PLATFORM, PlatformMacOS } from "@/util/platformutil";
Expand Down Expand Up @@ -38,6 +39,28 @@ function isBlank(v: string): boolean {
return v == null || v === "";
}

async function fileToVDomFileData(file: File, fieldname: string): Promise<VDomFileData> {
const maxSize = 5 * 1024 * 1024;
if (file.size > maxSize) {
return {
fieldname: fieldname,
name: file.name,
size: file.size,
type: file.type,
error: "File size exceeds 5MB limit",
};
}
const buffer = await file.arrayBuffer();
const data64 = arrayBufferToBase64(buffer);
return {
fieldname: fieldname,
name: file.name,
size: file.size,
type: file.type,
data64: data64,
};
}

function annotateEvent(event: VDomEvent, propName: string, reactEvent: React.SyntheticEvent) {
if (reactEvent == null) {
return;
Expand All @@ -47,7 +70,7 @@ function annotateEvent(event: VDomEvent, propName: string, reactEvent: React.Syn
event.targetvalue = changeEvent.target?.value;
event.targetchecked = changeEvent.target?.checked;
}
if (propName == "onClick" || propName == "onMouseDown") {
if (propName == "onClick" || propName == "onMouseDown" || propName == "onMouseUp" || propName == "onDoubleClick") {
const mouseEvent = reactEvent as React.MouseEvent<any>;
event.mousedata = {
button: mouseEvent.button,
Expand Down Expand Up @@ -79,6 +102,69 @@ function annotateEvent(event: VDomEvent, propName: string, reactEvent: React.Syn
}
}

async function asyncAnnotateEvent(event: VDomEvent, propName: string, reactEvent: React.SyntheticEvent) {
if (propName == "onSubmit") {
const formEvent = reactEvent as React.FormEvent<HTMLFormElement>;
const form = formEvent.currentTarget;

event.targetname = form.name;
event.targetid = form.id;

const formData: VDomFormData = {
method: (form.method || "get").toUpperCase(),
enctype: form.enctype || "application/x-www-form-urlencoded",
fields: {},
files: {},
};

if (form.action) {
formData.action = form.action;
}
if (form.id) {
formData.formid = form.id;
}
if (form.name) {
formData.formname = form.name;
}

const formDataObj = new FormData(form);

for (const [key, value] of formDataObj.entries()) {
if (value instanceof File) {
if (!value.name && value.size === 0) {
continue;
}
if (!formData.files[key]) {
formData.files[key] = [];
}
formData.files[key].push(await fileToVDomFileData(value, key));
} else {
if (!formData.fields[key]) {
formData.fields[key] = [];
}
formData.fields[key].push(value.toString());
}
}

event.formdata = formData;
}
if (propName == "onChange") {
const changeEvent = reactEvent as React.ChangeEvent<HTMLInputElement>;
if (changeEvent.target?.type === "file" && changeEvent.target.files) {
event.targetname = changeEvent.target.name;
event.targetid = changeEvent.target.id;

const files: VDomFileData[] = [];
const fieldname = changeEvent.target.name || changeEvent.target.id || "file";
for (let i = 0; i < changeEvent.target.files.length; i++) {
const file = changeEvent.target.files[i];
files.push(await fileToVDomFileData(file, fieldname));
}
event.targetfiles = files;
}
}
}

export class TsunamiModel {
clientId: string;
serverId: string;
Expand Down Expand Up @@ -109,7 +195,7 @@ export class TsunamiModel {
cachedTitle: string | null = null;
cachedShortDesc: string | null = null;
reason: string | null = null;
currentModal: jotai.PrimitiveAtom<ModalConfig | null> = jotai.atom(null);
currentModal: jotai.PrimitiveAtom<ModalConfig | null> = jotai.atom(null) as jotai.PrimitiveAtom<ModalConfig | null>;

constructor() {
this.clientId = getOrCreateClientId();
Expand Down Expand Up @@ -631,9 +717,23 @@ export class TsunamiModel {
if (fnDecl.globalevent) {
vdomEvent.globaleventtype = fnDecl.globalevent;
}
annotateEvent(vdomEvent, propName, e);
this.batchedEvents.push(vdomEvent);
this.queueUpdate(true, "event");
const needsAsync =
propName == "onSubmit" ||
(propName == "onChange" && (e.target as HTMLInputElement)?.type === "file");
if (needsAsync) {
asyncAnnotateEvent(vdomEvent, propName, e)
.then(() => {
this.batchedEvents.push(vdomEvent);
this.queueUpdate(true, "event");
})
.catch((err) => {
console.error("Error processing event:", err);
});
} else {
Comment on lines +720 to +732
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Restore deterministic ordering for async form/file events

Line 724 introduces an async branch that only pushes the event after fileToVDomFileData resolves. Because each handler now resolves independently, a later (smaller) file can finish first, enter the batch, and trigger queueUpdate before an earlier (larger) file has finished reading. The backend will then observe the events out of chronological order, breaking correctness for workflows that depend on deterministic sequencing of changes/submits. Please serialize these asynchronous annotations so they flush to batchedEvents in the exact order the handlers fire.

Apply this diff to chain async work per event:

         if (needsAsync) {
-            asyncAnnotateEvent(vdomEvent, propName, e)
-                .then(() => {
-                    this.batchedEvents.push(vdomEvent);
-                    this.queueUpdate(true, "event");
-                })
-                .catch((err) => {
-                    console.error("Error processing event:", err);
-                });
+            this.pendingAsyncEventChain = this.pendingAsyncEventChain
+                .then(async () => {
+                    await asyncAnnotateEvent(vdomEvent, propName, e);
+                    this.batchedEvents.push(vdomEvent);
+                    this.queueUpdate(true, "event");
+                })
+                .catch((err) => {
+                    console.error("Error processing event:", err);
+                });

And add the supporting field in the class body:

pendingAsyncEventChain: Promise<void> = Promise.resolve();
🤖 Prompt for AI Agents
In tsunami/frontend/src/model/tsunami-model.tsx around lines 720 to 732, async
event annotation for file/onSubmit handlers currently races and can flush
batchedEvents out of chronological order; add a class field
pendingAsyncEventChain: Promise<void> = Promise.resolve() and change the
needsAsync branch to serialize work by chaining onto pendingAsyncEventChain
(e.g. pendingAsyncEventChain = this.pendingAsyncEventChain.then(() =>
asyncAnnotateEvent(vdomEvent, propName, e).then(() => {
this.batchedEvents.push(vdomEvent); this.queueUpdate(true, "event");
}).catch(err => { console.error("Error processing event:", err); }))); this
preserves ordering by ensuring each async annotation+push/queue runs only after
the previous one completes and errors are handled so the chain continues.

annotateEvent(vdomEvent, propName, e);
this.batchedEvents.push(vdomEvent);
this.queueUpdate(true, "event");
}
}

createFeUpdate(): VDomFrontendUpdate {
Expand Down
23 changes: 23 additions & 0 deletions tsunami/frontend/src/types/vdom.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,10 @@ type VDomEvent = {
targetchecked?: boolean;
targetname?: string;
targetid?: string;
targetfiles?: VDomFileData[];
keydata?: VDomKeyboardEvent;
mousedata?: VDomPointerData;
formdata?: VDomFormData;
};

// vdom.VDomFrontendUpdate
Expand Down Expand Up @@ -204,3 +206,24 @@ type VDomPointerData = {
cmd?: boolean;
option?: boolean;
};

// vdom.VDomFormData
type VDomFormData = {
action?: string;
method: string;
enctype: string;
formid?: string;
formname?: string;
fields: { [key: string]: string[] };
files: { [key: string]: VDomFileData[] };
};

// vdom.VDomFileData
type VDomFileData = {
fieldname: string;
name: string;
size: number;
type: string;
data64?: string;
error?: string;
};
37 changes: 37 additions & 0 deletions tsunami/frontend/src/util/base64.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

import base64 from "base64-js";

export function base64ToString(b64: string): string {
if (b64 == null) {
return null;
}
if (b64 == "") {
return "";
}
const stringBytes = base64.toByteArray(b64);
return new TextDecoder().decode(stringBytes);
}

export function stringToBase64(input: string): string {
const stringBytes = new TextEncoder().encode(input);
return base64.fromByteArray(stringBytes);
}

export function base64ToArray(b64: string): Uint8Array<ArrayBufferLike> {
const cleanB64 = b64.replace(/\s+/g, "");
return base64.toByteArray(cleanB64);
}

export function base64ToArrayBuffer(b64: string): ArrayBuffer {
const cleanB64 = b64.replace(/\s+/g, "");
const u8 = base64.toByteArray(cleanB64); // Uint8Array<ArrayBufferLike>
// Force a plain ArrayBuffer slice (no SharedArrayBuffer, no offset issues)
return u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength) as ArrayBuffer;
}

export function arrayBufferToBase64(buffer: ArrayBuffer): string {
const u8 = new Uint8Array(buffer);
return base64.fromByteArray(u8);
}
44 changes: 38 additions & 6 deletions tsunami/vdom/vdom_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,14 @@ type VDomEvent struct {
WaveId string `json:"waveid"`
EventType string `json:"eventtype"` // usually the prop name (e.g. onClick, onKeyDown)
GlobalEventType string `json:"globaleventtype,omitempty"`
TargetValue string `json:"targetvalue,omitempty"`
TargetChecked bool `json:"targetchecked,omitempty"`
TargetName string `json:"targetname,omitempty"`
TargetId string `json:"targetid,omitempty"`
KeyData *VDomKeyboardEvent `json:"keydata,omitempty"`
MouseData *VDomPointerData `json:"mousedata,omitempty"`
TargetValue string `json:"targetvalue,omitempty"` // set for onChange events on input/textarea/select
TargetChecked bool `json:"targetchecked,omitempty"` // set for onChange events on checkbox/radio inputs
TargetName string `json:"targetname,omitempty"` // target element's name attribute
TargetId string `json:"targetid,omitempty"` // target element's id attribute
TargetFiles []VDomFileData `json:"targetfiles,omitempty"` // set for onChange events on file inputs
KeyData *VDomKeyboardEvent `json:"keydata,omitempty"` // set for onKeyDown events
MouseData *VDomPointerData `json:"mousedata,omitempty"` // set for onClick, onMouseDown, onMouseUp, onDoubleClick events
FormData *VDomFormData `json:"formdata,omitempty"` // set for onSubmit events on forms
}

type VDomKeyboardEvent struct {
Expand Down Expand Up @@ -112,6 +114,36 @@ type VDomPointerData struct {
Option bool `json:"option,omitempty"` // special (on mac it is alt, on windows/linux it is meta)
}

type VDomFormData struct {
Action string `json:"action,omitempty"`
Method string `json:"method"`
Enctype string `json:"enctype"`
FormId string `json:"formid,omitempty"`
FormName string `json:"formname,omitempty"`
Fields map[string][]string `json:"fields"`
Files map[string][]VDomFileData `json:"files"`
}

func (f *VDomFormData) GetField(fieldName string) string {
if f.Fields == nil {
return ""
}
values := f.Fields[fieldName]
if len(values) == 0 {
return ""
}
return values[0]
}

type VDomFileData struct {
FieldName string `json:"fieldname"`
Name string `json:"name"`
Size int64 `json:"size"`
Type string `json:"type"`
Data64 []byte `json:"data64,omitempty"`
Error string `json:"error,omitempty"`
}

type VDomRefOperation struct {
RefId string `json:"refid"`
Op string `json:"op"`
Expand Down
Loading