From d5d8c2d499f9c2bafa062e7b119f78c1edd10785 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 18 Jun 2025 21:41:10 -0700 Subject: [PATCH 01/22] prototype implementation --- src/js/packages/event-to-object/new/index.ts | 195 +++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 src/js/packages/event-to-object/new/index.ts diff --git a/src/js/packages/event-to-object/new/index.ts b/src/js/packages/event-to-object/new/index.ts new file mode 100644 index 000000000..cf9723c1c --- /dev/null +++ b/src/js/packages/event-to-object/new/index.ts @@ -0,0 +1,195 @@ +// Note, the build command is: bun build index.ts --outfile=index.js +// TODO: Test this with file uploads, form events, and other complex event types. +// TODO: Investigate these potentially useful libraries: +// https://www.npmjs.com/package/superserial#circular-reference +// https://www.npmjs.com/package/@badcafe/jsonizer +// https://www.npmjs.com/package/class-transformer#how-does-it-handle-circular-references +// https://www.npmjs.com/package/compress-json + +const stopSignal = { __stop__: true }; + +/** + * Convert any object to a plain object. + */ +function convert(object: object, maxDepth: number = 3): object { + // Begin conversion + const convertedObj = {}; + for (const key in object) { + // Skip keys that cannot be converted + if (shouldIgnore(object[key], key)) { + continue; + } + // Handle objects (potentially cyclical) + else if (typeof object[key] === "object") { + const result = deepClone(object[key], maxDepth); + convertedObj[key] = result; + } + // Handle simple types (non-cyclical) + else { + convertedObj[key] = object[key]; + } + } + return convertedObj; +} + +/** + * Recursively convert a complex object to a plain object. + */ +function deepClone(x: any, _maxDepth: number): object { + const maxDepth = _maxDepth - 1; + + // Return an indicator if maxDepth is reached + if (maxDepth <= 0 && typeof x === "object") { + return stopSignal; + } + + // Return early if already a plain object + if (isPlainObject(x)) { + return x; + } + + // Convert array-like class (e.g., NodeList, ClassList, HTMLCollection) + if ( + Array.isArray(x) || + (typeof x?.length === "number" && + typeof x[Symbol.iterator] === "function" && + !Object.prototype.toString.call(x).includes("Map") && + !(x instanceof CSSStyleDeclaration)) + ) { + return classToArray(x, maxDepth); + } + + // Convert mapping-like class (e.g., Node, Map, Set) + return classToObject(x, maxDepth); +} + +/** + * Convert an array-like class to a plain array. + */ +function classToArray(x: any, maxDepth: number): Array { + const result: Array = []; + for (let i = 0; i < x.length; i++) { + // Skip keys that cannot be converted + if (shouldIgnore(x[i])) { + continue; + } + // Only push objects as if we haven't reached max depth + else if (typeof x[i] === "object") { + const convertedItem = deepClone(x[i], maxDepth); + if (convertedItem !== stopSignal) { + result.push(convertedItem); + } + } + // Add plain values if not skippable + else { + result.push(x[i]); + } + } + return result; +} + +/** + * Convert a mapping-like class to a plain JSON object. + */ +function classToObject(x: any, maxDepth: number): object { + const result = {}; + for (const key in x) { + // Skip keys that cannot be converted + if (shouldIgnore(x[key], key, x)) { + continue; + } + // Add objects as a property if we haven't reached max depth + else if (typeof x[key] === "object") { + const convertedObj = deepClone(x[key], maxDepth); + if (convertedObj !== stopSignal) { + result[key] = convertedObj; + } + } + // Add plain values if not skippable + else { + result[key] = x[key]; + } + } + return result; +} + +/** + * Check if a value is non-convertible or holds minimal value. + */ +function shouldIgnore( + value: any, + keyName: string = "", + parent: any = undefined, +): boolean { + return ( + value === null || + value === undefined || + // value === "" || + typeof value === "function" || + value instanceof CSSStyleSheet || + value instanceof Window || + value instanceof Document || + keyName === "view" || + keyName === "size" || + keyName === "length" || + keyName.toUpperCase() === keyName || + (parent instanceof CSSStyleDeclaration && value === "") + ); +} + +/** + * Check if an object is a plain object. + * A plain object is an object created by `Object()` or squiggly braces `{}`. Additionally, + * `null` is treated as a plain object. + */ +function isPlainObject(x: any): boolean { + if (typeof x !== "object" || x === null) { + return false; + } + const proto = Object.getPrototypeOf(x); + return proto === Object.prototype || proto === null; +} + +// /** +// * Get the class name of an object. +// */ +// function getClassName(obj: object): string { +// return Function.prototype.call.bind(Object.prototype.toString)(obj); +// } +// +// /** +// * Get the index of an object in a set, or -1 if not found. +// */ +// function getObjectIndex(obj: object, set: Set): number { +// // Try to find the object by comparing JSON representation +// let index = 0; +// for (const item of set) { +// if ( +// typeof item === "object" && +// getClassName(item) === getClassName(obj) && +// JSON.stringify(item) == JSON.stringify(obj) +// ) { +// return index; +// } +// index++; +// } +// +// // If the object is not found in the set, return -1 +// return -1; +// } + +// Example usage of the convert function +var my_event = null; +function my_click(event) { + my_event = event; + console.log("Original Event:", event); + const jsonEvent = convert(event); + console.log("Converted Event:", JSON.stringify(jsonEvent, null, 2)); + console.log( + "Byte Length:", + new TextEncoder().encode(JSON.stringify(jsonEvent)).length, + ); +} +document + .getElementById("fetchData") + ?.addEventListener("click", my_click, false); From 596c44782c8416fce8e0e4d2b2aae9f61d7fa32f Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 5 Dec 2025 03:13:26 -0800 Subject: [PATCH 02/22] potential new design for event-to-object --- src/js/packages/event-to-object/new/index.ts | 195 ------- src/js/packages/event-to-object/src/index.ts | 549 +++++-------------- 2 files changed, 147 insertions(+), 597 deletions(-) delete mode 100644 src/js/packages/event-to-object/new/index.ts diff --git a/src/js/packages/event-to-object/new/index.ts b/src/js/packages/event-to-object/new/index.ts deleted file mode 100644 index cf9723c1c..000000000 --- a/src/js/packages/event-to-object/new/index.ts +++ /dev/null @@ -1,195 +0,0 @@ -// Note, the build command is: bun build index.ts --outfile=index.js -// TODO: Test this with file uploads, form events, and other complex event types. -// TODO: Investigate these potentially useful libraries: -// https://www.npmjs.com/package/superserial#circular-reference -// https://www.npmjs.com/package/@badcafe/jsonizer -// https://www.npmjs.com/package/class-transformer#how-does-it-handle-circular-references -// https://www.npmjs.com/package/compress-json - -const stopSignal = { __stop__: true }; - -/** - * Convert any object to a plain object. - */ -function convert(object: object, maxDepth: number = 3): object { - // Begin conversion - const convertedObj = {}; - for (const key in object) { - // Skip keys that cannot be converted - if (shouldIgnore(object[key], key)) { - continue; - } - // Handle objects (potentially cyclical) - else if (typeof object[key] === "object") { - const result = deepClone(object[key], maxDepth); - convertedObj[key] = result; - } - // Handle simple types (non-cyclical) - else { - convertedObj[key] = object[key]; - } - } - return convertedObj; -} - -/** - * Recursively convert a complex object to a plain object. - */ -function deepClone(x: any, _maxDepth: number): object { - const maxDepth = _maxDepth - 1; - - // Return an indicator if maxDepth is reached - if (maxDepth <= 0 && typeof x === "object") { - return stopSignal; - } - - // Return early if already a plain object - if (isPlainObject(x)) { - return x; - } - - // Convert array-like class (e.g., NodeList, ClassList, HTMLCollection) - if ( - Array.isArray(x) || - (typeof x?.length === "number" && - typeof x[Symbol.iterator] === "function" && - !Object.prototype.toString.call(x).includes("Map") && - !(x instanceof CSSStyleDeclaration)) - ) { - return classToArray(x, maxDepth); - } - - // Convert mapping-like class (e.g., Node, Map, Set) - return classToObject(x, maxDepth); -} - -/** - * Convert an array-like class to a plain array. - */ -function classToArray(x: any, maxDepth: number): Array { - const result: Array = []; - for (let i = 0; i < x.length; i++) { - // Skip keys that cannot be converted - if (shouldIgnore(x[i])) { - continue; - } - // Only push objects as if we haven't reached max depth - else if (typeof x[i] === "object") { - const convertedItem = deepClone(x[i], maxDepth); - if (convertedItem !== stopSignal) { - result.push(convertedItem); - } - } - // Add plain values if not skippable - else { - result.push(x[i]); - } - } - return result; -} - -/** - * Convert a mapping-like class to a plain JSON object. - */ -function classToObject(x: any, maxDepth: number): object { - const result = {}; - for (const key in x) { - // Skip keys that cannot be converted - if (shouldIgnore(x[key], key, x)) { - continue; - } - // Add objects as a property if we haven't reached max depth - else if (typeof x[key] === "object") { - const convertedObj = deepClone(x[key], maxDepth); - if (convertedObj !== stopSignal) { - result[key] = convertedObj; - } - } - // Add plain values if not skippable - else { - result[key] = x[key]; - } - } - return result; -} - -/** - * Check if a value is non-convertible or holds minimal value. - */ -function shouldIgnore( - value: any, - keyName: string = "", - parent: any = undefined, -): boolean { - return ( - value === null || - value === undefined || - // value === "" || - typeof value === "function" || - value instanceof CSSStyleSheet || - value instanceof Window || - value instanceof Document || - keyName === "view" || - keyName === "size" || - keyName === "length" || - keyName.toUpperCase() === keyName || - (parent instanceof CSSStyleDeclaration && value === "") - ); -} - -/** - * Check if an object is a plain object. - * A plain object is an object created by `Object()` or squiggly braces `{}`. Additionally, - * `null` is treated as a plain object. - */ -function isPlainObject(x: any): boolean { - if (typeof x !== "object" || x === null) { - return false; - } - const proto = Object.getPrototypeOf(x); - return proto === Object.prototype || proto === null; -} - -// /** -// * Get the class name of an object. -// */ -// function getClassName(obj: object): string { -// return Function.prototype.call.bind(Object.prototype.toString)(obj); -// } -// -// /** -// * Get the index of an object in a set, or -1 if not found. -// */ -// function getObjectIndex(obj: object, set: Set): number { -// // Try to find the object by comparing JSON representation -// let index = 0; -// for (const item of set) { -// if ( -// typeof item === "object" && -// getClassName(item) === getClassName(obj) && -// JSON.stringify(item) == JSON.stringify(obj) -// ) { -// return index; -// } -// index++; -// } -// -// // If the object is not found in the set, return -1 -// return -1; -// } - -// Example usage of the convert function -var my_event = null; -function my_click(event) { - my_event = event; - console.log("Original Event:", event); - const jsonEvent = convert(event); - console.log("Converted Event:", JSON.stringify(jsonEvent, null, 2)); - console.log( - "Byte Length:", - new TextEncoder().encode(JSON.stringify(jsonEvent)).length, - ); -} -document - .getElementById("fetchData") - ?.addEventListener("click", my_click, false); diff --git a/src/js/packages/event-to-object/src/index.ts b/src/js/packages/event-to-object/src/index.ts index 3f263af4d..7edf9c291 100644 --- a/src/js/packages/event-to-object/src/index.ts +++ b/src/js/packages/event-to-object/src/index.ts @@ -1,422 +1,167 @@ -import * as e from "./events"; - -export default function convert( - event: E, -): - | { - [K in keyof e.EventToObjectMap]: e.EventToObjectMap[K] extends [ - E, - infer P, - ] - ? P - : never; - }[keyof e.EventToObjectMap] - | e.EventObject - | null { - return event.type in eventConverters - ? eventConverters[event.type](event) - : convertEvent(event); -} - -const convertEvent = (event: Event): e.EventObject => ({ - /** Returns true or false depending on how event was initialized. True if event goes - * through its target's ancestors in reverse tree order, and false otherwise. */ - bubbles: event.bubbles, - composed: event.composed, - currentTarget: convertElement(event.currentTarget), - defaultPrevented: event.defaultPrevented, - eventPhase: event.eventPhase, - isTrusted: event.isTrusted, - target: convertElement(event.target), - timeStamp: event.timeStamp, - type: event.type, - selection: convertSelection(window.getSelection()), -}); - -const convertClipboardEvent = ( - event: ClipboardEvent, -): e.ClipboardEventObject => ({ - ...convertEvent(event), - clipboardData: convertDataTransferObject(event.clipboardData), -}); - -const convertCompositionEvent = ( - event: CompositionEvent, -): e.CompositionEventObject => ({ - ...convertUiEvent(event), - data: event.data, -}); - -const convertInputEvent = (event: InputEvent): e.InputEventObject => ({ - ...convertUiEvent(event), - data: event.data, - inputType: event.inputType, - dataTransfer: convertDataTransferObject(event.dataTransfer), - isComposing: event.isComposing, -}); - -const convertKeyboardEvent = (event: KeyboardEvent): e.KeyboardEventObject => ({ - ...convertUiEvent(event), - code: event.code, - isComposing: event.isComposing, - altKey: event.altKey, - ctrlKey: event.ctrlKey, - key: event.key, - location: event.location, - metaKey: event.metaKey, - repeat: event.repeat, - shiftKey: event.shiftKey, -}); - -const convertMouseEvent = (event: MouseEvent): e.MouseEventObject => ({ - ...convertEvent(event), - altKey: event.altKey, - button: event.button, - buttons: event.buttons, - clientX: event.clientX, - clientY: event.clientY, - ctrlKey: event.ctrlKey, - metaKey: event.metaKey, - pageX: event.pageX, - pageY: event.pageY, - screenX: event.screenX, - screenY: event.screenY, - shiftKey: event.shiftKey, - movementX: event.movementX, - movementY: event.movementY, - offsetX: event.offsetX, - offsetY: event.offsetY, - x: event.x, - y: event.y, - relatedTarget: convertElement(event.relatedTarget), -}); - -const convertTouchEvent = (event: TouchEvent): e.TouchEventObject => ({ - ...convertUiEvent(event), - altKey: event.altKey, - ctrlKey: event.ctrlKey, - metaKey: event.metaKey, - shiftKey: event.shiftKey, - touches: Array.from(event.touches).map(convertTouch), - changedTouches: Array.from(event.changedTouches).map(convertTouch), - targetTouches: Array.from(event.targetTouches).map(convertTouch), -}); - -const convertUiEvent = (event: UIEvent): e.UIEventObject => ({ - ...convertEvent(event), - detail: event.detail, -}); - -const convertAnimationEvent = ( - event: AnimationEvent, -): e.AnimationEventObject => ({ - ...convertEvent(event), - animationName: event.animationName, - pseudoElement: event.pseudoElement, - elapsedTime: event.elapsedTime, -}); - -const convertTransitionEvent = ( - event: TransitionEvent, -): e.TransitionEventObject => ({ - ...convertEvent(event), - propertyName: event.propertyName, - pseudoElement: event.pseudoElement, - elapsedTime: event.elapsedTime, -}); - -const convertFocusEvent = (event: FocusEvent): e.FocusEventObject => ({ - ...convertUiEvent(event), - relatedTarget: convertElement(event.relatedTarget), -}); - -const convertDeviceOrientationEvent = ( - event: DeviceOrientationEvent, -): e.DeviceOrientationEventObject => ({ - ...convertEvent(event), - absolute: event.absolute, - alpha: event.alpha, - beta: event.beta, - gamma: event.gamma, -}); - -const convertDragEvent = (event: DragEvent): e.DragEventObject => ({ - ...convertMouseEvent(event), - dataTransfer: convertDataTransferObject(event.dataTransfer), -}); - -const convertGamepadEvent = (event: GamepadEvent): e.GamepadEventObject => ({ - ...convertEvent(event), - gamepad: convertGamepad(event.gamepad), -}); - -const convertPointerEvent = (event: PointerEvent): e.PointerEventObject => ({ - ...convertMouseEvent(event), - pointerId: event.pointerId, - width: event.width, - height: event.height, - pressure: event.pressure, - tiltX: event.tiltX, - tiltY: event.tiltY, - pointerType: event.pointerType, - isPrimary: event.isPrimary, - tangentialPressure: event.tangentialPressure, - twist: event.twist, -}); - -const convertWheelEvent = (event: WheelEvent): e.WheelEventObject => ({ - ...convertMouseEvent(event), - deltaMode: event.deltaMode, - deltaX: event.deltaX, - deltaY: event.deltaY, - deltaZ: event.deltaZ, -}); +const maxDepthSignal = { __stop__: true }; + +/** + * Convert any class object (such as `Event`) to a plain object. + */ +export function convert( + classObject: { [key: string]: any }, + maxDepth: number = 3, +): object { + // Begin conversion + const convertedObj: { [key: string]: any } = {}; + for (const key in classObject) { + // Skip keys that cannot be converted + if (shouldIgnoreValue(classObject[key], key)) { + continue; + } + // Handle objects (potentially cyclical) + else if (typeof classObject[key] === "object") { + const result = deepCloneClass(classObject[key], maxDepth); + convertedObj[key] = result; + } + // Handle simple types (non-cyclical) + else { + convertedObj[key] = classObject[key]; + } + } -const convertSubmitEvent = (event: SubmitEvent): e.SubmitEventObject => ({ - ...convertEvent(event), - submitter: convertElement(event.submitter), -}); + // Special case: Event selection + if ( + typeof window !== "undefined" && + window.Event && + classObject instanceof window.Event + ) { + convertedObj["selection"] = serializeSelection(maxDepth); + } -const eventConverters: { [key: string]: (event: any) => any } = { - // animation events - animationcancel: convertAnimationEvent, - animationend: convertAnimationEvent, - animationiteration: convertAnimationEvent, - animationstart: convertAnimationEvent, - // input events - beforeinput: convertInputEvent, - // composition events - compositionend: convertCompositionEvent, - compositionstart: convertCompositionEvent, - compositionupdate: convertCompositionEvent, - // clipboard events - copy: convertClipboardEvent, - cut: convertClipboardEvent, - paste: convertClipboardEvent, - // device orientation events - deviceorientation: convertDeviceOrientationEvent, - // drag events - drag: convertDragEvent, - dragend: convertDragEvent, - dragenter: convertDragEvent, - dragleave: convertDragEvent, - dragover: convertDragEvent, - dragstart: convertDragEvent, - drop: convertDragEvent, - // ui events - error: convertUiEvent, - // focus events - blur: convertFocusEvent, - focus: convertFocusEvent, - focusin: convertFocusEvent, - focusout: convertFocusEvent, - // gamepad events - gamepadconnected: convertGamepadEvent, - gamepaddisconnected: convertGamepadEvent, - // keyboard events - keydown: convertKeyboardEvent, - keypress: convertKeyboardEvent, - keyup: convertKeyboardEvent, - // mouse events - auxclick: convertMouseEvent, - click: convertMouseEvent, - dblclick: convertMouseEvent, - contextmenu: convertMouseEvent, - mousedown: convertMouseEvent, - mouseenter: convertMouseEvent, - mouseleave: convertMouseEvent, - mousemove: convertMouseEvent, - mouseout: convertMouseEvent, - mouseover: convertMouseEvent, - mouseup: convertMouseEvent, - scroll: convertMouseEvent, - // pointer events - gotpointercapture: convertPointerEvent, - lostpointercapture: convertPointerEvent, - pointercancel: convertPointerEvent, - pointerdown: convertPointerEvent, - pointerenter: convertPointerEvent, - pointerleave: convertPointerEvent, - pointerlockchange: convertPointerEvent, - pointerlockerror: convertPointerEvent, - pointermove: convertPointerEvent, - pointerout: convertPointerEvent, - pointerover: convertPointerEvent, - pointerup: convertPointerEvent, - // submit events - submit: convertSubmitEvent, - // touch events - touchcancel: convertTouchEvent, - touchend: convertTouchEvent, - touchmove: convertTouchEvent, - touchstart: convertTouchEvent, - // transition events - transitioncancel: convertTransitionEvent, - transitionend: convertTransitionEvent, - transitionrun: convertTransitionEvent, - transitionstart: convertTransitionEvent, - // wheel events - wheel: convertWheelEvent, -}; + return convertedObj; +} -function convertElement(element: EventTarget | HTMLElement | null): any { - if (!element || !("tagName" in element)) { +/** + * Serialize the current window selection. + */ +function serializeSelection(maxDepth: number): object | null { + if (typeof window === "undefined" || !window.getSelection) { + return null; + } + const selection = window.getSelection(); + if (!selection) { return null; } - - const htmlElement = element as HTMLElement; - return { - ...convertGenericElement(htmlElement), - ...(htmlElement.tagName in elementConverters - ? elementConverters[htmlElement.tagName](htmlElement) - : {}), + type: selection.type, + anchorNode: selection.anchorNode + ? deepCloneClass(selection.anchorNode, maxDepth) + : null, + anchorOffset: selection.anchorOffset, + focusNode: selection.focusNode + ? deepCloneClass(selection.focusNode, maxDepth) + : null, + focusOffset: selection.focusOffset, + isCollapsed: selection.isCollapsed, + rangeCount: selection.rangeCount, + selectedText: selection.toString(), }; } -const convertGenericElement = (element: HTMLElement) => ({ - tagName: element.tagName, - boundingClientRect: { ...element.getBoundingClientRect() }, -}); - -const convertMediaElement = (element: HTMLMediaElement) => ({ - currentTime: element.currentTime, - duration: element.duration, - ended: element.ended, - error: element.error, - seeking: element.seeking, - volume: element.volume, -}); +/** + * Recursively convert a class-based object to a plain object. + */ +function deepCloneClass(x: any, _maxDepth: number): object { + const maxDepth = _maxDepth - 1; -const elementConverters: { [key: string]: (element: any) => any } = { - AUDIO: convertMediaElement, - BUTTON: (element: HTMLButtonElement) => ({ value: element.value }), - DATA: (element: HTMLDataElement) => ({ value: element.value }), - DATALIST: (element: HTMLDataListElement) => ({ - options: Array.from(element.options).map(elementConverters["OPTION"]), - }), - DIALOG: (element: HTMLDialogElement) => ({ - returnValue: element.returnValue, - }), - FIELDSET: (element: HTMLFieldSetElement) => ({ - elements: Array.from(element.elements).map(convertElement), - }), - FORM: (element: HTMLFormElement) => ({ - elements: Array.from(element.elements).map(convertElement), - }), - INPUT: (element: HTMLInputElement) => ({ - value: element.value, - checked: element.checked, - }), - METER: (element: HTMLMeterElement) => ({ value: element.value }), - OPTION: (element: HTMLOptionElement) => ({ value: element.value }), - OUTPUT: (element: HTMLOutputElement) => ({ value: element.value }), - PROGRESS: (element: HTMLProgressElement) => ({ value: element.value }), - SELECT: (element: HTMLSelectElement) => ({ value: element.value }), - TEXTAREA: (element: HTMLTextAreaElement) => ({ value: element.value }), - VIDEO: convertMediaElement, -}; - -const convertGamepad = (gamepad: Gamepad): e.GamepadObject => ({ - axes: Array.from(gamepad.axes), - buttons: Array.from(gamepad.buttons).map(convertGamepadButton), - connected: gamepad.connected, - id: gamepad.id, - index: gamepad.index, - mapping: gamepad.mapping, - timestamp: gamepad.timestamp, -}); + // Return an indicator if maxDepth is reached + if (maxDepth <= 0 && typeof x === "object") { + return maxDepthSignal; + } -const convertGamepadButton = ( - button: GamepadButton, -): e.GamepadButtonObject => ({ - pressed: button.pressed, - touched: button.touched, - value: button.value, -}); + // Convert array-like class (e.g., NodeList, ClassList, HTMLCollection) + if ( + Array.isArray(x) || + (typeof x?.length === "number" && + typeof x[Symbol.iterator] === "function" && + !Object.prototype.toString.call(x).includes("Map") && + !(x instanceof CSSStyleDeclaration)) + ) { + return classToArray(x, maxDepth); + } -const convertFile = (file: File) => ({ - lastModified: file.lastModified, - name: file.name, - size: file.size, - type: file.type, -}); + // Convert mapping-like class (e.g., Node, Map, Set) + return classToObject(x, maxDepth); +} -function convertDataTransferObject( - dataTransfer: DataTransfer | null, -): e.DataTransferObject | null { - if (!dataTransfer) { - return null; +/** + * Convert an array-like class to a plain array. + */ +function classToArray(x: any, maxDepth: number): Array { + const result: Array = []; + for (let i = 0; i < x.length; i++) { + // Skip anything that should not be converted + if (shouldIgnoreValue(x[i])) { + continue; + } + // Only push objects as if we haven't reached max depth + else if (typeof x[i] === "object") { + const converted = deepCloneClass(x[i], maxDepth); + if (converted !== maxDepthSignal) { + result.push(converted); + } + } + // Add plain values if not skippable + else { + result.push(x[i]); + } } - const { dropEffect, effectAllowed, files, items, types } = dataTransfer; - return { - dropEffect, - effectAllowed, - files: Array.from(files).map(convertFile), - items: Array.from(items).map((item) => ({ - kind: item.kind, - type: item.type, - })), - types: Array.from(types), - }; + return result; } -function convertSelection( - selection: Selection | null, -): e.SelectionObject | null { - if (!selection) { - return null; - } - const { - type, - anchorNode, - anchorOffset, - focusNode, - focusOffset, - isCollapsed, - rangeCount, - } = selection; - if (type === "None") { - return null; +/** + * Convert a mapping-like class to a plain JSON object. + * We must iterate through it with a for-loop in order to gain + * access to properties from all parent classes. + */ +function classToObject(x: any, maxDepth: number): object { + const result: { [key: string]: any } = {}; + for (const key in x) { + // Skip anything that should not be converted + if (shouldIgnoreValue(x[key], key, x)) { + continue; + } + // Add objects as a property if we haven't reached max depth + else if (typeof x[key] === "object") { + const converted = deepCloneClass(x[key], maxDepth); + if (converted !== maxDepthSignal) { + result[key] = converted; + } + } + // Add plain values if not skippable + else { + result[key] = x[key]; + } } - return { - type, - anchorNode: convertElement(anchorNode), - anchorOffset, - focusNode: convertElement(focusNode), - focusOffset, - isCollapsed, - rangeCount, - selectedText: selection.toString(), - }; + return result; } -function convertTouch({ - identifier, - pageX, - pageY, - screenX, - screenY, - clientX, - clientY, - force, - radiusX, - radiusY, - rotationAngle, - target, -}: Touch): e.TouchObject { - return { - identifier, - pageX, - pageY, - screenX, - screenY, - clientX, - clientY, - force, - radiusX, - radiusY, - rotationAngle, - target: convertElement(target), - }; +/** + * Check if a value is non-convertible or holds minimal value. + */ +function shouldIgnoreValue( + value: any, + keyName: string = "", + parent: any = undefined, +): boolean { + return ( + value === null || + value === undefined || + typeof value === "function" || + value instanceof CSSStyleSheet || + value instanceof Window || + value instanceof Document || + keyName === "view" || + keyName === "size" || + keyName === "length" || + (keyName.length > 0 && keyName.toUpperCase() === keyName) || + keyName.startsWith("__") || + (parent instanceof CSSStyleDeclaration && value === "") + ); } From 59c356c4e732c62ba3787831fda9ffb13c8f71d8 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 5 Dec 2025 03:17:04 -0800 Subject: [PATCH 03/22] new event-to-object tests --- .github/workflows/check.yml | 3 - src/js/bun.lockb | Bin 77154 -> 96562 bytes src/js/packages/event-to-object/package.json | 8 +- .../tests/event-to-object.test.ts | 21 +-- .../event-to-object/tests/tooling/check.ts | 131 +++++++++++++++--- .../event-to-object/tests/tooling/mock.ts | 8 +- .../event-to-object/tests/tooling/setup.js | 13 +- .../packages/event-to-object/vitest.config.ts | 8 ++ 8 files changed, 137 insertions(+), 55 deletions(-) create mode 100644 src/js/packages/event-to-object/vitest.config.ts diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index f66d14cb0..9c21380ca 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -38,9 +38,6 @@ jobs: run-cmd: "hatch run docs:check" python-version: '["3.11"]' test-javascript: - # Temporarily disabled while we rewrite the "event_to_object" package - # https://github.com/reactive-python/reactpy/issues/1196 - if: 0 uses: ./.github/workflows/.hatch-run.yml with: job-name: "{1}" diff --git a/src/js/bun.lockb b/src/js/bun.lockb index 9c1c23061f459b1134072b02d5db83b7922645c6..7ec3a10fdc05d7974631c2a80600a04a20b1dfbc 100644 GIT binary patch delta 23862 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 = { types: string[]; @@ -255,8 +250,8 @@ const simpleTestCases: SimpleTestCase[] = [ pressure: 0, tiltX: 0, tiltY: 0, - width: 0, - height: 0, + width: 1, + height: 1, isPrimary: false, twist: 0, tangentialPressure: 0, @@ -360,14 +355,14 @@ test("adds text of current selection", () => { `; const start = document.getElementById("start"); const end = document.getElementById("end"); - window.getSelection()!.setBaseAndExtent(start!, 0, end!, 0); + window.getSelection()!.setBaseAndExtent(start! as any, 0, end! as any, 0); checkEventConversion(new window.Event("fake"), { type: "fake", selection: { type: "Range", - anchorNode: { ...mockElementObject, tagName: "P" }, + anchorNode: {}, anchorOffset: 0, - focusNode: { ...mockElementObject, tagName: "P" }, + focusNode: {}, focusOffset: 0, isCollapsed: false, rangeCount: 1, @@ -377,5 +372,3 @@ test("adds text of current selection", () => { isTrusted: undefined, }); }); - -test.run(); diff --git a/src/js/packages/event-to-object/tests/tooling/check.ts b/src/js/packages/event-to-object/tests/tooling/check.ts index 33ff5ed5b..fdf037c42 100644 --- a/src/js/packages/event-to-object/tests/tooling/check.ts +++ b/src/js/packages/event-to-object/tests/tooling/check.ts @@ -1,46 +1,131 @@ -import * as assert from "uvu/assert"; +import { expect } from "bun:test"; import { Event } from "happy-dom"; // @ts-ignore import lodash from "lodash"; -import convert from "../../src/index"; +import { convert } from "../../src/index"; export function checkEventConversion( givenEvent: Event, expectedConversion: any, ): void { + // Patch happy-dom event to make standard properties enumerable and defined + const standardProps = [ + "bubbles", + "cancelable", + "composed", + "currentTarget", + "defaultPrevented", + "eventPhase", + "isTrusted", + "target", + "type", + "srcElement", + "returnValue", + "altKey", + "metaKey", + "ctrlKey", + "shiftKey", + "elapsedTime", + "propertyName", + "pseudoElement", + ]; + + for (const prop of standardProps) { + if (prop in givenEvent) { + try { + Object.defineProperty(givenEvent, prop, { + enumerable: true, + value: (givenEvent as any)[prop], + writable: true, + configurable: true, + }); + } catch (e) {} + } + } + + // timeStamp is special + try { + Object.defineProperty(givenEvent, "timeStamp", { + enumerable: true, + value: givenEvent.timeStamp || Date.now(), + writable: true, + configurable: true, + }); + } catch (e) {} + + // Patch undefined properties that are expected to be 0 or null + const defaults: any = { + offsetX: 0, + offsetY: 0, + layerX: 0, + layerY: 0, + pageX: 0, + pageY: 0, + x: 0, + y: 0, + screenX: 0, + screenY: 0, + movementX: 0, + movementY: 0, + detail: 0, + which: 0, + relatedTarget: null, + }; + + for (const [key, value] of Object.entries(defaults)) { + if ((givenEvent as any)[key] === undefined && key in givenEvent) { + try { + Object.defineProperty(givenEvent, key, { + enumerable: true, + value: value, + writable: true, + configurable: true, + }); + } catch (e) {} + } + } + const actualSerializedEvent = convert( // @ts-ignore givenEvent, + 5, ); if (!actualSerializedEvent) { - assert.equal(actualSerializedEvent, expectedConversion); + expect(actualSerializedEvent).toEqual(expectedConversion); return; } // too hard to compare - assert.equal(typeof actualSerializedEvent.timeStamp, "number"); - - assert.equal( - actualSerializedEvent, - lodash.merge( - { timeStamp: actualSerializedEvent.timeStamp, type: givenEvent.type }, - expectedConversionDefaults, - expectedConversion, - ), + // @ts-ignore + expect(typeof actualSerializedEvent.timeStamp).toBe("number"); + + // Remove nulls from expectedConversionDefaults because convert() strips nulls + const comparisonDefaults = { + bubbles: false, + cancelable: false, + composed: false, + defaultPrevented: false, + eventPhase: 0, + }; + + const expected = lodash.merge( + // @ts-ignore + { timeStamp: actualSerializedEvent.timeStamp, type: givenEvent.type }, + comparisonDefaults, + expectedConversion, ); + // Remove keys from expected that are null or undefined, because convert() strips them + for (const key in expected) { + if (expected[key] === null || expected[key] === undefined) { + delete expected[key]; + } + } + + // Use toMatchObject to allow extra properties in actual (like layerX, detail, etc.) + expect(actualSerializedEvent).toMatchObject(expected); + // verify result is JSON serializable JSON.stringify(actualSerializedEvent); } - -const expectedConversionDefaults = { - target: null, - currentTarget: null, - bubbles: false, - composed: false, - defaultPrevented: false, - eventPhase: undefined, - isTrusted: undefined, - selection: null, -}; diff --git a/src/js/packages/event-to-object/tests/tooling/mock.ts b/src/js/packages/event-to-object/tests/tooling/mock.ts index e9f1d03a4..f118003a2 100644 --- a/src/js/packages/event-to-object/tests/tooling/mock.ts +++ b/src/js/packages/event-to-object/tests/tooling/mock.ts @@ -9,11 +9,6 @@ export const mockBoundingRect = { width: 0, }; -export const mockElementObject = { - tagName: null, - boundingClientRect: mockBoundingRect, -}; - export const mockElement = { tagName: null, getBoundingClientRect: () => mockBoundingRect, @@ -32,7 +27,6 @@ export const mockGamepad = { value: 0, }, ], - timestamp: undefined, }; export const mockTouch = { @@ -52,5 +46,5 @@ export const mockTouch = { export const mockTouchObject = { ...mockTouch, - target: mockElementObject, + target: {}, }; diff --git a/src/js/packages/event-to-object/tests/tooling/setup.js b/src/js/packages/event-to-object/tests/tooling/setup.js index 213578046..12b99fa41 100644 --- a/src/js/packages/event-to-object/tests/tooling/setup.js +++ b/src/js/packages/event-to-object/tests/tooling/setup.js @@ -1,5 +1,5 @@ -import { test } from "uvu"; import { Window } from "happy-dom"; +import { beforeAll, beforeEach } from "bun:test"; export const window = new Window(); @@ -9,6 +9,13 @@ export function setup() { global.navigator = window.navigator; global.getComputedStyle = window.getComputedStyle; global.requestAnimationFrame = null; + global.CSSStyleSheet = window.CSSStyleSheet; + global.CSSStyleDeclaration = window.CSSStyleDeclaration; + global.Window = window.constructor; + global.Document = window.document.constructor; + global.Node = window.Node; + global.Element = window.Element; + global.HTMLElement = window.HTMLElement; } export function reset() { @@ -18,5 +25,5 @@ export function reset() { window.getSelection().removeAllRanges(); } -test.before(setup); -test.before.each(reset); +beforeAll(setup); +beforeEach(reset); diff --git a/src/js/packages/event-to-object/vitest.config.ts b/src/js/packages/event-to-object/vitest.config.ts new file mode 100644 index 000000000..c92f3607e --- /dev/null +++ b/src/js/packages/event-to-object/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + environment: "happy-dom", + }, +}); From 33c5cefd6eacece5887c2541a4fff5b02d928d4e Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 5 Dec 2025 03:25:21 -0800 Subject: [PATCH 04/22] refactor: change convert function to default export --- src/js/packages/event-to-object/src/index.ts | 2 +- .../packages/event-to-object/tests/tooling/check.ts | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/js/packages/event-to-object/src/index.ts b/src/js/packages/event-to-object/src/index.ts index 7edf9c291..98fb5d1c1 100644 --- a/src/js/packages/event-to-object/src/index.ts +++ b/src/js/packages/event-to-object/src/index.ts @@ -3,7 +3,7 @@ const maxDepthSignal = { __stop__: true }; /** * Convert any class object (such as `Event`) to a plain object. */ -export function convert( +export default function convert( classObject: { [key: string]: any }, maxDepth: number = 3, ): object { diff --git a/src/js/packages/event-to-object/tests/tooling/check.ts b/src/js/packages/event-to-object/tests/tooling/check.ts index fdf037c42..02edc466e 100644 --- a/src/js/packages/event-to-object/tests/tooling/check.ts +++ b/src/js/packages/event-to-object/tests/tooling/check.ts @@ -39,7 +39,9 @@ export function checkEventConversion( writable: true, configurable: true, }); - } catch (e) {} + } catch { + // ignore + } } } @@ -51,7 +53,9 @@ export function checkEventConversion( writable: true, configurable: true, }); - } catch (e) {} + } catch { + // ignore + } // Patch undefined properties that are expected to be 0 or null const defaults: any = { @@ -81,7 +85,9 @@ export function checkEventConversion( writable: true, configurable: true, }); - } catch (e) {} + } catch { + // ignore + } } } From 617ef91846f0d511b4479af647c80a83143e2a72 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 5 Dec 2025 03:36:37 -0800 Subject: [PATCH 05/22] add test for data-* attributes --- src/js/packages/event-to-object/src/index.ts | 17 ++++++++++ .../tests/event-to-object.test.ts | 33 +++++++++++++++++++ .../event-to-object/tests/tooling/check.ts | 2 +- 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/js/packages/event-to-object/src/index.ts b/src/js/packages/event-to-object/src/index.ts index 98fb5d1c1..360ea53b5 100644 --- a/src/js/packages/event-to-object/src/index.ts +++ b/src/js/packages/event-to-object/src/index.ts @@ -139,6 +139,23 @@ function classToObject(x: any, maxDepth: number): object { result[key] = x[key]; } } + + // Explicitly include dataset if it exists (it might not be enumerable) + if ( + x && + typeof x === "object" && + "dataset" in x && + !Object.prototype.hasOwnProperty.call(result, "dataset") + ) { + const dataset = x["dataset"]; + if (!shouldIgnoreValue(dataset, "dataset", x)) { + const converted = deepCloneClass(dataset, maxDepth); + if (converted !== maxDepthSignal) { + result["dataset"] = converted; + } + } + } + return result; } diff --git a/src/js/packages/event-to-object/tests/event-to-object.test.ts b/src/js/packages/event-to-object/tests/event-to-object.test.ts index 8227a2b91..9989e9eee 100644 --- a/src/js/packages/event-to-object/tests/event-to-object.test.ts +++ b/src/js/packages/event-to-object/tests/event-to-object.test.ts @@ -372,3 +372,36 @@ test("adds text of current selection", () => { isTrusted: undefined, }); }); + +test("includes data-* attributes in dataset", () => { + const div = document.createElement("div"); + div.setAttribute("data-test-value", "123"); + div.setAttribute("data-other", "foo"); + + const event = new window.Event("click"); + Object.defineProperty(event, "target", { + value: div, + enumerable: true, + writable: true, + }); + Object.defineProperty(event, "currentTarget", { + value: div, + enumerable: true, + writable: true, + }); + + checkEventConversion(event, { + target: { + dataset: { + testValue: "123", + other: "foo", + }, + }, + currentTarget: { + dataset: { + testValue: "123", + other: "foo", + }, + }, + }); +}); diff --git a/src/js/packages/event-to-object/tests/tooling/check.ts b/src/js/packages/event-to-object/tests/tooling/check.ts index 02edc466e..835823ad1 100644 --- a/src/js/packages/event-to-object/tests/tooling/check.ts +++ b/src/js/packages/event-to-object/tests/tooling/check.ts @@ -2,7 +2,7 @@ import { expect } from "bun:test"; import { Event } from "happy-dom"; // @ts-ignore import lodash from "lodash"; -import { convert } from "../../src/index"; +import convert from "../../src/index"; export function checkEventConversion( givenEvent: Event, From 9039795d6774baf9f8b2315e14eb39ee09948ee4 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 5 Dec 2025 03:40:40 -0800 Subject: [PATCH 06/22] Update hatch for new test config --- pyproject.toml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3e7eb041f..97d4c5d94 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -182,10 +182,7 @@ check = [ 'bun run --cwd "src/js/packages/@reactpy/app" checkTypes', ] fix = ['bun install --cwd "src/js"', 'bun run --cwd "src/js" format'] -test = [ - 'hatch run javascript:build_event_to_object', - 'bun run --cwd "src/js/packages/event-to-object" test', -] +test = ['hatch run javascript:build_event_to_object', 'bun test'] build = [ 'hatch run "src/build_scripts/clean_js_dir.py"', 'bun install --cwd "src/js"', From 258c86a296c24497ec70e34b6f098ac511edfcf3 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 5 Dec 2025 03:44:01 -0800 Subject: [PATCH 07/22] Add tests for 'on' checkbox properties --- src/js/packages/event-to-object/src/index.ts | 23 +++++++ .../tests/event-to-object.test.ts | 61 ++++++++++++++++++- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/src/js/packages/event-to-object/src/index.ts b/src/js/packages/event-to-object/src/index.ts index 360ea53b5..4c193966f 100644 --- a/src/js/packages/event-to-object/src/index.ts +++ b/src/js/packages/event-to-object/src/index.ts @@ -156,6 +156,29 @@ function classToObject(x: any, maxDepth: number): object { } } + // Explicitly include common input properties if they exist + const extraProps = ["value", "checked", "files", "type"]; + for (const prop of extraProps) { + if ( + x && + typeof x === "object" && + prop in x && + !Object.prototype.hasOwnProperty.call(result, prop) + ) { + const val = x[prop]; + if (!shouldIgnoreValue(val, prop, x)) { + if (typeof val === "object") { + const converted = deepCloneClass(val, maxDepth); + if (converted !== maxDepthSignal) { + result[prop] = converted; + } + } else { + result[prop] = val; + } + } + } + } + return result; } diff --git a/src/js/packages/event-to-object/tests/event-to-object.test.ts b/src/js/packages/event-to-object/tests/event-to-object.test.ts index 9989e9eee..48cb86cbb 100644 --- a/src/js/packages/event-to-object/tests/event-to-object.test.ts +++ b/src/js/packages/event-to-object/tests/event-to-object.test.ts @@ -1,7 +1,8 @@ // @ts-ignore import { window } from "./tooling/setup"; -import { test } from "bun:test"; +import { test, expect } from "bun:test"; import { Event } from "happy-dom"; +import convert from "../src/index"; import { checkEventConversion } from "./tooling/check"; import { mockGamepad, mockTouch, mockTouchObject } from "./tooling/mock"; @@ -405,3 +406,61 @@ test("includes data-* attributes in dataset", () => { }, }); }); + +test("includes value and checked for radio and checkbox inputs", () => { + const radio = document.createElement("input"); + radio.type = "radio"; + radio.checked = true; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = true; + + const radioEvent = new window.Event("change"); + Object.defineProperty(radioEvent, "target", { + value: radio, + enumerable: true, + writable: true, + }); + + checkEventConversion(radioEvent, { + target: { + value: "on", + checked: true, + type: "radio", + }, + }); + + const checkboxEvent = new window.Event("change"); + Object.defineProperty(checkboxEvent, "target", { + value: checkbox, + enumerable: true, + writable: true, + }); + + checkEventConversion(checkboxEvent, { + target: { + value: "on", + checked: true, + type: "checkbox", + }, + }); +}); + +test("excludes 'on' properties when missing", () => { + const div = document.createElement("div"); + div.onclick = () => {}; + // @ts-ignore + div.oncustom = null; + + const event = new window.Event("click"); + Object.defineProperty(event, "target", { + value: div, + enumerable: true, + writable: true, + }); + + const converted: any = convert(event); + expect(converted.target.onclick).toBeUndefined(); + expect(converted.target.oncustom).toBeUndefined(); +}); From 941b6927adb282667bc8e5935342de4761fd2e81 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 5 Dec 2025 03:47:07 -0800 Subject: [PATCH 08/22] Add test to ensure name property exists on inputs --- src/js/packages/event-to-object/src/index.ts | 2 +- .../tests/event-to-object.test.ts | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/js/packages/event-to-object/src/index.ts b/src/js/packages/event-to-object/src/index.ts index 4c193966f..fc6e28060 100644 --- a/src/js/packages/event-to-object/src/index.ts +++ b/src/js/packages/event-to-object/src/index.ts @@ -157,7 +157,7 @@ function classToObject(x: any, maxDepth: number): object { } // Explicitly include common input properties if they exist - const extraProps = ["value", "checked", "files", "type"]; + const extraProps = ["value", "checked", "files", "type", "name"]; for (const prop of extraProps) { if ( x && diff --git a/src/js/packages/event-to-object/tests/event-to-object.test.ts b/src/js/packages/event-to-object/tests/event-to-object.test.ts index 48cb86cbb..199d0a24d 100644 --- a/src/js/packages/event-to-object/tests/event-to-object.test.ts +++ b/src/js/packages/event-to-object/tests/event-to-object.test.ts @@ -464,3 +464,23 @@ test("excludes 'on' properties when missing", () => { expect(converted.target.onclick).toBeUndefined(); expect(converted.target.oncustom).toBeUndefined(); }); + +test("includes name property for inputs", () => { + const input = document.createElement("input"); + input.name = "test-input"; + input.value = "test-value"; + + const event = new window.Event("change"); + Object.defineProperty(event, "target", { + value: input, + enumerable: true, + writable: true, + }); + + checkEventConversion(event, { + target: { + name: "test-input", + value: "test-value", + }, + }); +}); From 8c9ea0fdfe0c3afa3ad1579b1d62c81bca19650a Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 5 Dec 2025 03:48:52 -0800 Subject: [PATCH 09/22] Add a test for toggleable checked property on checkboxes --- .../tests/event-to-object.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/js/packages/event-to-object/tests/event-to-object.test.ts b/src/js/packages/event-to-object/tests/event-to-object.test.ts index 199d0a24d..e4f2af16a 100644 --- a/src/js/packages/event-to-object/tests/event-to-object.test.ts +++ b/src/js/packages/event-to-object/tests/event-to-object.test.ts @@ -484,3 +484,40 @@ test("includes name property for inputs", () => { }, }); }); + +test("includes checked property for checkboxes", () => { + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + + // Test checked = true + checkbox.checked = true; + let event = new window.Event("change"); + Object.defineProperty(event, "target", { + value: checkbox, + enumerable: true, + writable: true, + }); + + checkEventConversion(event, { + target: { + checked: true, + type: "checkbox", + }, + }); + + // Test checked = false + checkbox.checked = false; + event = new window.Event("change"); + Object.defineProperty(event, "target", { + value: checkbox, + enumerable: true, + writable: true, + }); + + checkEventConversion(event, { + target: { + checked: false, + type: "checkbox", + }, + }); +}); From 5e270c7997f04e9c8ebab476fa8894ea1ab39fd3 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 5 Dec 2025 03:54:29 -0800 Subject: [PATCH 10/22] hatch fmt auto fixes --- src/reactpy/_console/rewrite_keys.py | 4 ++-- src/reactpy/core/component.py | 2 +- src/reactpy/core/hooks.py | 4 ++-- src/reactpy/core/vdom.py | 1 - src/reactpy/web/utils.py | 2 +- 5 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/reactpy/_console/rewrite_keys.py b/src/reactpy/_console/rewrite_keys.py index 6f7a42f1e..bd44bf099 100644 --- a/src/reactpy/_console/rewrite_keys.py +++ b/src/reactpy/_console/rewrite_keys.py @@ -102,7 +102,7 @@ def log_could_not_rewrite(file: Path, tree: ast.AST) -> None: if ( name == "vdom" - or hasattr(html, name) - and any(kw.arg == "key" for kw in node.keywords) + or (hasattr(html, name) + and any(kw.arg == "key" for kw in node.keywords)) ): click.echo(f"Unable to rewrite usage at {file}:{node.lineno}") diff --git a/src/reactpy/core/component.py b/src/reactpy/core/component.py index d2cfcfe31..e8b16fae2 100644 --- a/src/reactpy/core/component.py +++ b/src/reactpy/core/component.py @@ -34,7 +34,7 @@ def constructor(*args: Any, key: Any | None = None, **kwargs: Any) -> Component: class Component: """An object for rending component models.""" - __slots__ = "__weakref__", "_func", "_args", "_kwargs", "_sig", "key", "type" + __slots__ = "__weakref__", "_args", "_func", "_kwargs", "_sig", "key", "type" def __init__( self, diff --git a/src/reactpy/core/hooks.py b/src/reactpy/core/hooks.py index 8fc7db703..f02a8cf5b 100644 --- a/src/reactpy/core/hooks.py +++ b/src/reactpy/core/hooks.py @@ -79,7 +79,7 @@ def use_state(initial_value: _Type | Callable[[], _Type]) -> State[_Type]: class _CurrentState(Generic[_Type]): - __slots__ = "value", "dispatch" + __slots__ = "dispatch", "value" def __init__( self, @@ -534,7 +534,7 @@ def setup(function: Callable[[], _Type]) -> _Type: class _Memo(Generic[_Type]): """Simple object for storing memoization data""" - __slots__ = "value", "deps" + __slots__ = "deps", "value" value: _Type deps: Sequence[Any] diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index 8d70af53d..14db23bf6 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -217,7 +217,6 @@ def separate_attributes_and_children( _attributes: VdomAttributes children_or_iterables: Sequence[Any] - # ruff: noqa: E721 if type(values[0]) is dict: _attributes, *children_or_iterables = values else: diff --git a/src/reactpy/web/utils.py b/src/reactpy/web/utils.py index 338fa504a..d6ef62dc4 100644 --- a/src/reactpy/web/utils.py +++ b/src/reactpy/web/utils.py @@ -12,7 +12,7 @@ def module_name_suffix(name: str) -> str: if name.startswith("@"): name = name[1:] head, _, tail = name.partition("@") # handle version identifier - version, _, tail = tail.partition("/") # get section after version + _, _, tail = tail.partition("/") # get section after version return PurePosixPath(tail or head).suffix or ".js" From 6f5902be6aeea6c0c1710646c46d9f40c3ee0e80 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 5 Dec 2025 04:00:37 -0800 Subject: [PATCH 11/22] fix python lint errors with new hatch version --- pyproject.toml | 2 ++ src/reactpy/_console/rewrite_keys.py | 6 ++---- src/reactpy/core/events.py | 27 +++++++++++++-------------- src/reactpy/utils.py | 2 ++ 4 files changed, 19 insertions(+), 18 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 97d4c5d94..7d3f82b16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -305,6 +305,8 @@ lint.ignore = [ "PLR0912", "PLR0913", "PLR0915", + # Allow imports anywhere + "PLC0415", ] lint.unfixable = [ # Don't touch unused imports diff --git a/src/reactpy/_console/rewrite_keys.py b/src/reactpy/_console/rewrite_keys.py index bd44bf099..e5ae20710 100644 --- a/src/reactpy/_console/rewrite_keys.py +++ b/src/reactpy/_console/rewrite_keys.py @@ -100,9 +100,7 @@ def log_could_not_rewrite(file: Path, tree: ast.AST) -> None: else: continue - if ( - name == "vdom" - or (hasattr(html, name) - and any(kw.arg == "key" for kw in node.keywords)) + if name == "vdom" or ( + hasattr(html, name) and any(kw.arg == "key" for kw in node.keywords) ): click.echo(f"Unable to rewrite usage at {file}:{node.lineno}") diff --git a/src/reactpy/core/events.py b/src/reactpy/core/events.py index fc6eca04f..34c0d0c04 100644 --- a/src/reactpy/core/events.py +++ b/src/reactpy/core/events.py @@ -69,10 +69,7 @@ def setup(function: Callable[..., Any]) -> EventHandler: prevent_default, ) - if function is not None: - return setup(function) - else: - return setup + return setup(function) if function is not None else setup class EventHandler: @@ -109,18 +106,20 @@ def __init__( self.stop_propagation = stop_propagation self.target = target + __hash__ = None # type: ignore + def __eq__(self, other: object) -> bool: undefined = object() - for attr in ( - "function", - "prevent_default", - "stop_propagation", - "target", - ): - if not attr.startswith("_"): - if not getattr(other, attr, undefined) == getattr(self, attr): - return False - return True + return not any( + not attr.startswith("_") + and not getattr(other, attr, undefined) == getattr(self, attr) + for attr in ( + "function", + "prevent_default", + "stop_propagation", + "target", + ) + ) def __repr__(self) -> str: public_names = [name for name in self.__slots__ if not name.startswith("_")] diff --git a/src/reactpy/utils.py b/src/reactpy/utils.py index 8f7a757d3..315413845 100644 --- a/src/reactpy/utils.py +++ b/src/reactpy/utils.py @@ -45,6 +45,8 @@ def set_current(self, new: _RefValue) -> _RefValue: self.current = new return old + __hash__ = None # type: ignore + def __eq__(self, other: object) -> bool: try: return isinstance(other, Ref) and (other.current == self.current) From 8559900d0fb12087c192c968157b4adfe3a67b34 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 6 Dec 2025 04:02:17 -0800 Subject: [PATCH 12/22] revert hatch version due to dependency resolution bugs --- .github/workflows/.hatch-run.yml | 2 +- src/reactpy/core/vdom.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/.hatch-run.yml b/.github/workflows/.hatch-run.yml index c8770d184..fc794755e 100644 --- a/.github/workflows/.hatch-run.yml +++ b/.github/workflows/.hatch-run.yml @@ -43,7 +43,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install Python Dependencies - run: pip install --upgrade hatch uv + run: pip install --upgrade hatch<1.16 uv - name: Run Scripts env: NPM_CONFIG_TOKEN: ${{ secrets.node-auth-token }} diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index 14db23bf6..2c7eaaa19 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -217,7 +217,7 @@ def separate_attributes_and_children( _attributes: VdomAttributes children_or_iterables: Sequence[Any] - if type(values[0]) is dict: + if type(values[0]) is dict: # noqa: E721 _attributes, *children_or_iterables = values else: _attributes = {} From 061f5f5a30f93d9da2cb57d24022ada51465f3b4 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 6 Dec 2025 04:04:48 -0800 Subject: [PATCH 13/22] try another pip install cmd --- .github/workflows/.hatch-run.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/.hatch-run.yml b/.github/workflows/.hatch-run.yml index fc794755e..57ec84422 100644 --- a/.github/workflows/.hatch-run.yml +++ b/.github/workflows/.hatch-run.yml @@ -43,7 +43,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install Python Dependencies - run: pip install --upgrade hatch<1.16 uv + run: pip install hatch<1.16 - name: Run Scripts env: NPM_CONFIG_TOKEN: ${{ secrets.node-auth-token }} From e0ef341043f0ee26a55a54262e3d44d20351f845 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 6 Dec 2025 04:07:37 -0800 Subject: [PATCH 14/22] try again --- .github/workflows/.hatch-run.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/.hatch-run.yml b/.github/workflows/.hatch-run.yml index 57ec84422..4299b38d6 100644 --- a/.github/workflows/.hatch-run.yml +++ b/.github/workflows/.hatch-run.yml @@ -43,7 +43,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install Python Dependencies - run: pip install hatch<1.16 + run: pip install "hatch<1.16" - name: Run Scripts env: NPM_CONFIG_TOKEN: ${{ secrets.node-auth-token }} From be7ed36f3bc64ba582eef8463ca75199ddfe7225 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 6 Dec 2025 04:49:25 -0800 Subject: [PATCH 15/22] handle file metadata in input fields --- src/js/packages/event-to-object/src/index.ts | 33 +++++++- .../tests/event-to-object.test.ts | 83 +++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/src/js/packages/event-to-object/src/index.ts b/src/js/packages/event-to-object/src/index.ts index fc6e28060..b76aa7e7d 100644 --- a/src/js/packages/event-to-object/src/index.ts +++ b/src/js/packages/event-to-object/src/index.ts @@ -168,7 +168,9 @@ function classToObject(x: any, maxDepth: number): object { const val = x[prop]; if (!shouldIgnoreValue(val, prop, x)) { if (typeof val === "object") { - const converted = deepCloneClass(val, maxDepth); + // Ensure files have enough depth to be serialized + const propDepth = prop === "files" ? Math.max(maxDepth, 3) : maxDepth; + const converted = deepCloneClass(val, propDepth); if (converted !== maxDepthSignal) { result[prop] = converted; } @@ -179,6 +181,35 @@ function classToObject(x: any, maxDepth: number): object { } } + // Explicitly include form elements if they exist and are not enumerable + const win = typeof window !== "undefined" ? window : undefined; + // @ts-ignore + const FormClass = win + ? win.HTMLFormElement + : typeof HTMLFormElement !== "undefined" + ? HTMLFormElement + : undefined; + + if (FormClass && x instanceof FormClass && x.elements) { + for (let i = 0; i < x.elements.length; i++) { + const element = x.elements[i] as any; + if ( + element.name && + !Object.prototype.hasOwnProperty.call(result, element.name) && + !shouldIgnoreValue(element, element.name, x) + ) { + if (typeof element === "object") { + const converted = deepCloneClass(element, maxDepth); + if (converted !== maxDepthSignal) { + result[element.name] = converted; + } + } else { + result[element.name] = element; + } + } + } + } + return result; } diff --git a/src/js/packages/event-to-object/tests/event-to-object.test.ts b/src/js/packages/event-to-object/tests/event-to-object.test.ts index e4f2af16a..26bdbde30 100644 --- a/src/js/packages/event-to-object/tests/event-to-object.test.ts +++ b/src/js/packages/event-to-object/tests/event-to-object.test.ts @@ -521,3 +521,86 @@ test("includes checked property for checkboxes", () => { }, }); }); + +test("converts file input with files", () => { + const input = window.document.createElement("input"); + input.type = "file"; + + // Create a mock file + const file = new window.File(["content"], "test.txt", { + type: "text/plain", + lastModified: 1234567890, + }); + + // Mock the files property + const mockFileList = { + 0: file, + length: 1, + item: (index: number) => (index === 0 ? file : null), + [Symbol.iterator]: function* () { + yield file; + }, + }; + + Object.defineProperty(input, "files", { + value: mockFileList, + writable: true, + }); + + const event = new window.Event("change"); + Object.defineProperty(event, "target", { + value: input, + enumerable: true, + writable: true, + }); + + const converted: any = convert(event); + + expect(converted.target.files).toBeDefined(); + expect(converted.target.files.length).toBe(1); + expect(converted.target.files[0].name).toBe("test.txt"); +}); + +test("converts form submission with file input", () => { + const form = window.document.createElement("form"); + const input = window.document.createElement("input"); + input.type = "file"; + input.name = "myFile"; + + // Create a mock file + const file = new window.File(["content"], "test.txt", { + type: "text/plain", + lastModified: 1234567890, + }); + + // Mock the files property + const mockFileList = { + 0: file, + length: 1, + item: (index: number) => (index === 0 ? file : null), + [Symbol.iterator]: function* () { + yield file; + }, + }; + + Object.defineProperty(input, "files", { + value: mockFileList, + writable: true, + }); + + form.appendChild(input); + + const event = new window.Event("submit"); + Object.defineProperty(event, "target", { + value: form, + enumerable: true, + writable: true, + }); + + const converted: any = convert(event); + + expect(converted.target.myFile).toBeDefined(); + expect(converted.target.myFile.files).toBeDefined(); + expect(converted.target.myFile.files.length).toBe(1); + expect(converted.target.myFile.files[0].name).toBe("test.txt"); +}); From 930f1e271509562add3111f26ae365d1300c3f74 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 6 Dec 2025 04:54:01 -0800 Subject: [PATCH 16/22] Increase max depth --- src/js/packages/event-to-object/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/packages/event-to-object/src/index.ts b/src/js/packages/event-to-object/src/index.ts index b76aa7e7d..dad94cbfa 100644 --- a/src/js/packages/event-to-object/src/index.ts +++ b/src/js/packages/event-to-object/src/index.ts @@ -5,7 +5,7 @@ const maxDepthSignal = { __stop__: true }; */ export default function convert( classObject: { [key: string]: any }, - maxDepth: number = 3, + maxDepth: number = 10, ): object { // Begin conversion const convertedObj: { [key: string]: any } = {}; From ebd7759392ce68af6ccc2d753e1710a9d683157d Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 6 Dec 2025 05:11:50 -0800 Subject: [PATCH 17/22] handle infinite recursion --- src/js/packages/event-to-object/src/index.ts | 79 +++++++++++++------ .../tests/event-to-object.test.ts | 66 ++++++++++++++++ 2 files changed, 120 insertions(+), 25 deletions(-) diff --git a/src/js/packages/event-to-object/src/index.ts b/src/js/packages/event-to-object/src/index.ts index dad94cbfa..d0dd2c384 100644 --- a/src/js/packages/event-to-object/src/index.ts +++ b/src/js/packages/event-to-object/src/index.ts @@ -7,6 +7,9 @@ export default function convert( classObject: { [key: string]: any }, maxDepth: number = 10, ): object { + const visited = new WeakSet(); + visited.add(classObject); + // Begin conversion const convertedObj: { [key: string]: any } = {}; for (const key in classObject) { @@ -16,8 +19,10 @@ export default function convert( } // Handle objects (potentially cyclical) else if (typeof classObject[key] === "object") { - const result = deepCloneClass(classObject[key], maxDepth); - convertedObj[key] = result; + const result = deepCloneClass(classObject[key], maxDepth, visited); + if (result !== maxDepthSignal) { + convertedObj[key] = result; + } } // Handle simple types (non-cyclical) else { @@ -31,7 +36,7 @@ export default function convert( window.Event && classObject instanceof window.Event ) { - convertedObj["selection"] = serializeSelection(maxDepth); + convertedObj["selection"] = serializeSelection(maxDepth, visited); } return convertedObj; @@ -40,7 +45,10 @@ export default function convert( /** * Serialize the current window selection. */ -function serializeSelection(maxDepth: number): object | null { +function serializeSelection( + maxDepth: number, + visited: WeakSet, +): object | null { if (typeof window === "undefined" || !window.getSelection) { return null; } @@ -51,11 +59,11 @@ function serializeSelection(maxDepth: number): object | null { return { type: selection.type, anchorNode: selection.anchorNode - ? deepCloneClass(selection.anchorNode, maxDepth) + ? deepCloneClass(selection.anchorNode, maxDepth, visited) : null, anchorOffset: selection.anchorOffset, focusNode: selection.focusNode - ? deepCloneClass(selection.focusNode, maxDepth) + ? deepCloneClass(selection.focusNode, maxDepth, visited) : null, focusOffset: selection.focusOffset, isCollapsed: selection.isCollapsed, @@ -67,7 +75,11 @@ function serializeSelection(maxDepth: number): object | null { /** * Recursively convert a class-based object to a plain object. */ -function deepCloneClass(x: any, _maxDepth: number): object { +function deepCloneClass( + x: any, + _maxDepth: number, + visited: WeakSet, +): object { const maxDepth = _maxDepth - 1; // Return an indicator if maxDepth is reached @@ -75,25 +87,38 @@ function deepCloneClass(x: any, _maxDepth: number): object { return maxDepthSignal; } - // Convert array-like class (e.g., NodeList, ClassList, HTMLCollection) - if ( - Array.isArray(x) || - (typeof x?.length === "number" && - typeof x[Symbol.iterator] === "function" && - !Object.prototype.toString.call(x).includes("Map") && - !(x instanceof CSSStyleDeclaration)) - ) { - return classToArray(x, maxDepth); + if (visited.has(x)) { + return maxDepthSignal; } + visited.add(x); - // Convert mapping-like class (e.g., Node, Map, Set) - return classToObject(x, maxDepth); + try { + // Convert array-like class (e.g., NodeList, ClassList, HTMLCollection) + if ( + Array.isArray(x) || + (typeof x?.length === "number" && + typeof x[Symbol.iterator] === "function" && + !Object.prototype.toString.call(x).includes("Map") && + !(x instanceof CSSStyleDeclaration)) + ) { + return classToArray(x, maxDepth, visited); + } + + // Convert mapping-like class (e.g., Node, Map, Set) + return classToObject(x, maxDepth, visited); + } finally { + visited.delete(x); + } } /** * Convert an array-like class to a plain array. */ -function classToArray(x: any, maxDepth: number): Array { +function classToArray( + x: any, + maxDepth: number, + visited: WeakSet, +): Array { const result: Array = []; for (let i = 0; i < x.length; i++) { // Skip anything that should not be converted @@ -102,7 +127,7 @@ function classToArray(x: any, maxDepth: number): Array { } // Only push objects as if we haven't reached max depth else if (typeof x[i] === "object") { - const converted = deepCloneClass(x[i], maxDepth); + const converted = deepCloneClass(x[i], maxDepth, visited); if (converted !== maxDepthSignal) { result.push(converted); } @@ -120,7 +145,11 @@ function classToArray(x: any, maxDepth: number): Array { * We must iterate through it with a for-loop in order to gain * access to properties from all parent classes. */ -function classToObject(x: any, maxDepth: number): object { +function classToObject( + x: any, + maxDepth: number, + visited: WeakSet, +): object { const result: { [key: string]: any } = {}; for (const key in x) { // Skip anything that should not be converted @@ -129,7 +158,7 @@ function classToObject(x: any, maxDepth: number): object { } // Add objects as a property if we haven't reached max depth else if (typeof x[key] === "object") { - const converted = deepCloneClass(x[key], maxDepth); + const converted = deepCloneClass(x[key], maxDepth, visited); if (converted !== maxDepthSignal) { result[key] = converted; } @@ -149,7 +178,7 @@ function classToObject(x: any, maxDepth: number): object { ) { const dataset = x["dataset"]; if (!shouldIgnoreValue(dataset, "dataset", x)) { - const converted = deepCloneClass(dataset, maxDepth); + const converted = deepCloneClass(dataset, maxDepth, visited); if (converted !== maxDepthSignal) { result["dataset"] = converted; } @@ -170,7 +199,7 @@ function classToObject(x: any, maxDepth: number): object { if (typeof val === "object") { // Ensure files have enough depth to be serialized const propDepth = prop === "files" ? Math.max(maxDepth, 3) : maxDepth; - const converted = deepCloneClass(val, propDepth); + const converted = deepCloneClass(val, propDepth, visited); if (converted !== maxDepthSignal) { result[prop] = converted; } @@ -199,7 +228,7 @@ function classToObject(x: any, maxDepth: number): object { !shouldIgnoreValue(element, element.name, x) ) { if (typeof element === "object") { - const converted = deepCloneClass(element, maxDepth); + const converted = deepCloneClass(element, maxDepth, visited); if (converted !== maxDepthSignal) { result[element.name] = converted; } diff --git a/src/js/packages/event-to-object/tests/event-to-object.test.ts b/src/js/packages/event-to-object/tests/event-to-object.test.ts index 26bdbde30..30f87bedf 100644 --- a/src/js/packages/event-to-object/tests/event-to-object.test.ts +++ b/src/js/packages/event-to-object/tests/event-to-object.test.ts @@ -604,3 +604,69 @@ test("converts form submission with file input", () => { expect(converted.target.myFile.files.length).toBe(1); expect(converted.target.myFile.files[0].name).toBe("test.txt"); }); + +test("handles recursive structures", () => { + // Direct recursion + const recursive: any = { a: 1 }; + recursive.self = recursive; + + const converted: any = convert(recursive); + expect(converted.a).toBe(1); + expect(converted.self).toBeUndefined(); + + // Indirect recursion + const indirect: any = { name: "root" }; + const child: any = { name: "child" }; + indirect.child = child; + child.parent = indirect; + + const convertedIndirect: any = convert(indirect); + expect(convertedIndirect.name).toBe("root"); + expect(convertedIndirect.child.name).toBe("child"); + expect(convertedIndirect.child.parent).toBeUndefined(); +}); + +test("handles shared references without stopping", () => { + const shared = { name: "shared" }; + const root = { + left: { item: shared }, + right: { item: shared }, + }; + + const converted: any = convert(root); + expect(converted.left.item.name).toBe("shared"); + expect(converted.right.item.name).toBe("shared"); + expect(converted.left.item).not.toEqual({ __stop__: true }); + expect(converted.right.item).not.toEqual({ __stop__: true }); +}); + +test("handles recursive HTML node structures", () => { + const parent = window.document.createElement("div"); + const child = window.document.createElement("span"); + parent.appendChild(child); + + // Add explicit circular references to ensure we test recursion + // even if standard DOM properties are not enumerable in this environment. + (parent as any).circular = parent; + (child as any).parentLink = parent; + (parent as any).childLink = child; + + const converted: any = convert(parent); + + // Verify explicit cycle is handled + expect(converted.circular).toBeUndefined(); + + // Verify child link is handled + if (converted.childLink) { + expect(converted.childLink.parentLink).toBeUndefined(); + } + + // If the DOM implementation enumerates parentNode, it should be handled gracefully + if ( + converted.children && + converted.children.length > 0 && + converted.children[0].parentNode + ) { + expect(converted.children[0].parentNode).toBeUndefined(); + } +}); From 76d18f982dd0145b456331b485402cb77992d4ee Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 6 Dec 2025 05:52:10 -0800 Subject: [PATCH 18/22] Rewrite event-to-object package for improved event property handling --- docs/source/about/changelog.rst | 1 + src/js/packages/@reactpy/client/package.json | 2 +- src/js/packages/event-to-object/package.json | 4 +- src/js/packages/event-to-object/src/index.ts | 65 ++++++++++++-------- tests/conftest.py | 9 ++- 5 files changed, 51 insertions(+), 30 deletions(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index bb4ea5e7f..f64911892 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -49,6 +49,7 @@ Unreleased - :pull:`1281` - ``reactpy.core.vdom._CustomVdomDictConstructor`` has been moved to ``reactpy.types.CustomVdomConstructor``. - :pull:`1281` - ``reactpy.core.vdom._EllipsisRepr`` has been moved to ``reactpy.types.EllipsisRepr``. - :pull:`1281` - ``reactpy.types.VdomDictConstructor`` has been renamed to ``reactpy.types.VdomConstructor``. +- :pull:`1196` - Rewrite the ``event-to-object`` package to be more robust at handling properties on events. **Removed** diff --git a/src/js/packages/@reactpy/client/package.json b/src/js/packages/@reactpy/client/package.json index a6873b044..7f766ef04 100644 --- a/src/js/packages/@reactpy/client/package.json +++ b/src/js/packages/@reactpy/client/package.json @@ -1,5 +1,5 @@ { - "author": "Ryan Morshead", + "author": "Mark Bakhit", "dependencies": { "json-pointer": "catalog:", "preact": "catalog:", diff --git a/src/js/packages/event-to-object/package.json b/src/js/packages/event-to-object/package.json index 644071157..a2ac85b93 100644 --- a/src/js/packages/event-to-object/package.json +++ b/src/js/packages/event-to-object/package.json @@ -1,5 +1,5 @@ { - "author": "Ryan Morshead", + "author": "Mark Bakhit", "dependencies": { "json-pointer": "catalog:" }, @@ -28,5 +28,5 @@ "checkTypes": "tsc --noEmit" }, "type": "module", - "version": "0.1.2" + "version": "1.0.0" } diff --git a/src/js/packages/event-to-object/src/index.ts b/src/js/packages/event-to-object/src/index.ts index d0dd2c384..93175285c 100644 --- a/src/js/packages/event-to-object/src/index.ts +++ b/src/js/packages/event-to-object/src/index.ts @@ -5,7 +5,7 @@ const maxDepthSignal = { __stop__: true }; */ export default function convert( classObject: { [key: string]: any }, - maxDepth: number = 10, + maxDepth: number = 5, ): object { const visited = new WeakSet(); visited.add(classObject); @@ -14,19 +14,23 @@ export default function convert( const convertedObj: { [key: string]: any } = {}; for (const key in classObject) { // Skip keys that cannot be converted - if (shouldIgnoreValue(classObject[key], key)) { - continue; - } - // Handle objects (potentially cyclical) - else if (typeof classObject[key] === "object") { - const result = deepCloneClass(classObject[key], maxDepth, visited); - if (result !== maxDepthSignal) { - convertedObj[key] = result; + try { + if (shouldIgnoreValue(classObject[key], key)) { + continue; } - } - // Handle simple types (non-cyclical) - else { - convertedObj[key] = classObject[key]; + // Handle objects (potentially cyclical) + else if (typeof classObject[key] === "object") { + const result = deepCloneClass(classObject[key], maxDepth, visited); + if (result !== maxDepthSignal) { + convertedObj[key] = result; + } + } + // Handle simple types (non-cyclical) + else { + convertedObj[key] = classObject[key]; + } + } catch (e) { + continue; } } @@ -87,6 +91,11 @@ function deepCloneClass( return maxDepthSignal; } + // Safety check: WeakSet only accepts objects (and not null) + if (!x || typeof x !== "object") { + return x; + } + if (visited.has(x)) { return maxDepthSignal; } @@ -152,20 +161,24 @@ function classToObject( ): object { const result: { [key: string]: any } = {}; for (const key in x) { - // Skip anything that should not be converted - if (shouldIgnoreValue(x[key], key, x)) { - continue; - } - // Add objects as a property if we haven't reached max depth - else if (typeof x[key] === "object") { - const converted = deepCloneClass(x[key], maxDepth, visited); - if (converted !== maxDepthSignal) { - result[key] = converted; + try { + // Skip anything that should not be converted + if (shouldIgnoreValue(x[key], key, x)) { + continue; } - } - // Add plain values if not skippable - else { - result[key] = x[key]; + // Add objects as a property if we haven't reached max depth + else if (typeof x[key] === "object") { + const converted = deepCloneClass(x[key], maxDepth, visited); + if (converted !== maxDepthSignal) { + result[key] = converted; + } + } + // Add plain values if not skippable + else { + result[key] = x[key]; + } + } catch (e) { + continue; } } diff --git a/tests/conftest.py b/tests/conftest.py index 3c8eb9b23..368078e74 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -42,7 +42,14 @@ def install_playwright(): @pytest.fixture(autouse=True, scope="session") def rebuild(): - subprocess.run(["hatch", "build", "-t", "wheel"], check=True) # noqa: S607, S603 + # When running inside `hatch test`, the `HATCH_ENV_ACTIVE` environment variable + # is set. If we try to run `hatch build` with this variable set, Hatch will + # complain that the current environment is not a builder environment. + # To fix this, we remove `HATCH_ENV_ACTIVE` from the environment variables + # passed to the subprocess. + env = os.environ.copy() + env.pop("HATCH_ENV_ACTIVE", None) + subprocess.run(["hatch", "build", "-t", "wheel"], check=True, env=env) # noqa: S607, S603 @pytest.fixture(autouse=True, scope="function") From d5f4ff5f75ce59c844a88ace5191c3b5eb265f83 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 6 Dec 2025 06:17:22 -0800 Subject: [PATCH 19/22] Increase default maxDepth and refine key processing for DOM Nodes in conversion --- src/js/packages/event-to-object/src/index.ts | 43 ++++++++++++++++++-- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/src/js/packages/event-to-object/src/index.ts b/src/js/packages/event-to-object/src/index.ts index 93175285c..b05a9da6c 100644 --- a/src/js/packages/event-to-object/src/index.ts +++ b/src/js/packages/event-to-object/src/index.ts @@ -5,7 +5,7 @@ const maxDepthSignal = { __stop__: true }; */ export default function convert( classObject: { [key: string]: any }, - maxDepth: number = 5, + maxDepth: number = 10, ): object { const visited = new WeakSet(); visited.add(classObject); @@ -264,8 +264,12 @@ function shouldIgnoreValue( parent: any = undefined, ): boolean { return ( + // Useless data value === null || value === undefined || + keyName.startsWith("__") || + (keyName.length > 0 && keyName.toUpperCase() === keyName) || + // Non-convertible types typeof value === "function" || value instanceof CSSStyleSheet || value instanceof Window || @@ -273,8 +277,39 @@ function shouldIgnoreValue( keyName === "view" || keyName === "size" || keyName === "length" || - (keyName.length > 0 && keyName.toUpperCase() === keyName) || - keyName.startsWith("__") || - (parent instanceof CSSStyleDeclaration && value === "") + (parent instanceof CSSStyleDeclaration && value === "") || + // DOM Node Blacklist + (typeof Node !== "undefined" && + parent instanceof Node && + // Recursive properties + (keyName === "parentNode" || + keyName === "parentElement" || + keyName === "ownerDocument" || + keyName === "getRootNode" || + keyName === "childNodes" || + keyName === "children" || + keyName === "firstChild" || + keyName === "lastChild" || + keyName === "previousSibling" || + keyName === "nextSibling" || + keyName === "previousElementSibling" || + keyName === "nextElementSibling" || + // Potentially large data + keyName === "innerHTML" || + keyName === "outerHTML" || + // Reflow triggers + keyName === "offsetParent" || + keyName === "offsetWidth" || + keyName === "offsetHeight" || + keyName === "offsetLeft" || + keyName === "offsetTop" || + keyName === "clientTop" || + keyName === "clientLeft" || + keyName === "clientWidth" || + keyName === "clientHeight" || + keyName === "scrollWidth" || + keyName === "scrollHeight" || + keyName === "scrollTop" || + keyName === "scrollLeft")) ); } From 422dc8c0cb0881873264276cfd1bd32429b9866e Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 6 Dec 2025 06:53:33 -0800 Subject: [PATCH 20/22] fix js lint --- src/js/packages/event-to-object/src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/js/packages/event-to-object/src/index.ts b/src/js/packages/event-to-object/src/index.ts index b05a9da6c..6d62ae098 100644 --- a/src/js/packages/event-to-object/src/index.ts +++ b/src/js/packages/event-to-object/src/index.ts @@ -29,7 +29,7 @@ export default function convert( else { convertedObj[key] = classObject[key]; } - } catch (e) { + } catch { continue; } } @@ -177,7 +177,7 @@ function classToObject( else { result[key] = x[key]; } - } catch (e) { + } catch { continue; } } From 5fa88b8d1287db1df27234a32a155f29f7de4218 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 6 Dec 2025 21:08:23 -0800 Subject: [PATCH 21/22] switch to hatch >=v1.16.2 --- .github/workflows/.hatch-run.yml | 2 +- pyproject.toml | 2 +- src/reactpy/core/vdom.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/.hatch-run.yml b/.github/workflows/.hatch-run.yml index 4299b38d6..d210675a4 100644 --- a/.github/workflows/.hatch-run.yml +++ b/.github/workflows/.hatch-run.yml @@ -43,7 +43,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install Python Dependencies - run: pip install "hatch<1.16" + run: pip install hatch - name: Run Scripts env: NPM_CONFIG_TOKEN: ${{ secrets.node-auth-token }} diff --git a/pyproject.toml b/pyproject.toml index 7d3f82b16..4eefdd243 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,7 +86,6 @@ artifacts = [] [tool.hatch.envs.hatch-test] extra-dependencies = [ - "reactpy[all]", "pytest-sugar", "pytest-asyncio", "responses", @@ -94,6 +93,7 @@ extra-dependencies = [ "jsonpointer", "starlette", ] +features = ["all"] [[tool.hatch.envs.hatch-test.matrix]] python = ["3.10", "3.11", "3.12", "3.13"] diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index 2c7eaaa19..14db23bf6 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -217,7 +217,7 @@ def separate_attributes_and_children( _attributes: VdomAttributes children_or_iterables: Sequence[Any] - if type(values[0]) is dict: # noqa: E721 + if type(values[0]) is dict: _attributes, *children_or_iterables = values else: _attributes = {} From e5f4bd402bb4e54e166b43fb523a73724fab1609 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 6 Dec 2025 22:26:43 -0800 Subject: [PATCH 22/22] resolve co-pilot review comments --- src/js/packages/@reactpy/client/package.json | 3 +++ src/js/packages/event-to-object/package.json | 3 +++ src/js/packages/event-to-object/src/index.ts | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/js/packages/@reactpy/client/package.json b/src/js/packages/@reactpy/client/package.json index 7f766ef04..0285743de 100644 --- a/src/js/packages/@reactpy/client/package.json +++ b/src/js/packages/@reactpy/client/package.json @@ -1,5 +1,8 @@ { "author": "Mark Bakhit", + "contributors": [ + "Ryan Morshead" + ], "dependencies": { "json-pointer": "catalog:", "preact": "catalog:", diff --git a/src/js/packages/event-to-object/package.json b/src/js/packages/event-to-object/package.json index a2ac85b93..51aa3df24 100644 --- a/src/js/packages/event-to-object/package.json +++ b/src/js/packages/event-to-object/package.json @@ -1,5 +1,8 @@ { "author": "Mark Bakhit", + "contributors": [ + "Ryan Morshead" + ], "dependencies": { "json-pointer": "catalog:" }, diff --git a/src/js/packages/event-to-object/src/index.ts b/src/js/packages/event-to-object/src/index.ts index 6d62ae098..84d6c5f65 100644 --- a/src/js/packages/event-to-object/src/index.ts +++ b/src/js/packages/event-to-object/src/index.ts @@ -268,7 +268,7 @@ function shouldIgnoreValue( value === null || value === undefined || keyName.startsWith("__") || - (keyName.length > 0 && keyName.toUpperCase() === keyName) || + (keyName.length > 0 && /^[A-Z_]+$/.test(keyName)) || // Non-convertible types typeof value === "function" || value instanceof CSSStyleSheet ||