diff --git a/.github/workflows/.hatch-run.yml b/.github/workflows/.hatch-run.yml index c8770d184..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 --upgrade hatch uv + run: pip install hatch - name: Run Scripts env: NPM_CONFIG_TOKEN: ${{ secrets.node-auth-token }} 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/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/pyproject.toml b/pyproject.toml index 3e7eb041f..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"] @@ -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"', @@ -308,6 +305,8 @@ lint.ignore = [ "PLR0912", "PLR0913", "PLR0915", + # Allow imports anywhere + "PLC0415", ] lint.unfixable = [ # Don't touch unused imports diff --git a/src/js/bun.lockb b/src/js/bun.lockb index 9c1c23061..7ec3a10fd 100644 Binary files a/src/js/bun.lockb and b/src/js/bun.lockb differ diff --git a/src/js/packages/@reactpy/client/package.json b/src/js/packages/@reactpy/client/package.json index a6873b044..0285743de 100644 --- a/src/js/packages/@reactpy/client/package.json +++ b/src/js/packages/@reactpy/client/package.json @@ -1,5 +1,8 @@ { - "author": "Ryan Morshead", + "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 86c2b0563..51aa3df24 100644 --- a/src/js/packages/event-to-object/package.json +++ b/src/js/packages/event-to-object/package.json @@ -1,15 +1,17 @@ { - "author": "Ryan Morshead", + "author": "Mark Bakhit", + "contributors": [ + "Ryan Morshead" + ], "dependencies": { "json-pointer": "catalog:" }, "description": "Converts a JavaScript events to JSON serializable objects.", "devDependencies": { - "happy-dom": "^8.9.0", + "happy-dom": "^15.0.0", "lodash": "^4.17.21", - "tsm": "^2.3.0", "typescript": "^5.8.3", - "uvu": "^0.5.6" + "vitest": "^2.1.8" }, "keywords": [ "event", @@ -26,9 +28,8 @@ }, "scripts": { "build": "tsc -b", - "checkTypes": "tsc --noEmit", - "test": "uvu -r tsm tests" + "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 3f263af4d..84d6c5f65 100644 --- a/src/js/packages/event-to-object/src/index.ts +++ b/src/js/packages/event-to-object/src/index.ts @@ -1,422 +1,315 @@ -import * as e from "./events"; +const maxDepthSignal = { __stop__: true }; -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), -}); +/** + * Convert any class object (such as `Event`) to a plain object. + */ +export default function convert( + classObject: { [key: string]: any }, + maxDepth: number = 10, +): object { + const visited = new WeakSet(); + visited.add(classObject); -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, -}); + // Begin conversion + const convertedObj: { [key: string]: any } = {}; + for (const key in classObject) { + // Skip keys that cannot be converted + try { + 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; + } + } + // Handle simple types (non-cyclical) + else { + convertedObj[key] = classObject[key]; + } + } catch { + continue; + } + } -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, visited); + } -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, + visited: WeakSet, +): 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, visited) + : null, + anchorOffset: selection.anchorOffset, + focusNode: selection.focusNode + ? deepCloneClass(selection.focusNode, maxDepth, visited) + : null, + focusOffset: selection.focusOffset, + isCollapsed: selection.isCollapsed, + rangeCount: selection.rangeCount, + selectedText: selection.toString(), }; } -const convertGenericElement = (element: HTMLElement) => ({ - tagName: element.tagName, - boundingClientRect: { ...element.getBoundingClientRect() }, -}); +/** + * Recursively convert a class-based object to a plain object. + */ +function deepCloneClass( + x: any, + _maxDepth: number, + visited: WeakSet, +): object { + const maxDepth = _maxDepth - 1; -const convertMediaElement = (element: HTMLMediaElement) => ({ - currentTime: element.currentTime, - duration: element.duration, - ended: element.ended, - error: element.error, - seeking: element.seeking, - volume: element.volume, -}); + // Return an indicator if maxDepth is reached + if (maxDepth <= 0 && typeof x === "object") { + return maxDepthSignal; + } -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, -}; + // Safety check: WeakSet only accepts objects (and not null) + if (!x || typeof x !== "object") { + return x; + } -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, -}); + if (visited.has(x)) { + return maxDepthSignal; + } + visited.add(x); -const convertGamepadButton = ( - button: GamepadButton, -): e.GamepadButtonObject => ({ - pressed: button.pressed, - touched: button.touched, - value: button.value, -}); + 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); + } -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, visited); + } finally { + visited.delete(x); + } +} -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, + visited: WeakSet, +): 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, visited); + 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; +/** + * 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, + visited: WeakSet, +): object { + const result: { [key: string]: any } = {}; + for (const key in x) { + try { + // 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; + } + } + // Add plain values if not skippable + else { + result[key] = x[key]; + } + } catch { + continue; + } } - const { - type, - anchorNode, - anchorOffset, - focusNode, - focusOffset, - isCollapsed, - rangeCount, - } = selection; - if (type === "None") { - return null; + + // 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, visited); + if (converted !== maxDepthSignal) { + result["dataset"] = converted; + } + } } - return { - type, - anchorNode: convertElement(anchorNode), - anchorOffset, - focusNode: convertElement(focusNode), - focusOffset, - isCollapsed, - rangeCount, - selectedText: selection.toString(), - }; + + // Explicitly include common input properties if they exist + const extraProps = ["value", "checked", "files", "type", "name"]; + 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") { + // Ensure files have enough depth to be serialized + const propDepth = prop === "files" ? Math.max(maxDepth, 3) : maxDepth; + const converted = deepCloneClass(val, propDepth, visited); + if (converted !== maxDepthSignal) { + result[prop] = converted; + } + } else { + result[prop] = val; + } + } + } + } + + // 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, visited); + if (converted !== maxDepthSignal) { + result[element.name] = converted; + } + } else { + result[element.name] = element; + } + } + } + } + + 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 ( + // Useless data + value === null || + value === undefined || + keyName.startsWith("__") || + (keyName.length > 0 && /^[A-Z_]+$/.test(keyName)) || + // Non-convertible types + typeof value === "function" || + value instanceof CSSStyleSheet || + value instanceof Window || + value instanceof Document || + keyName === "view" || + keyName === "size" || + keyName === "length" || + (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")) + ); } 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 b7b8c68af..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 @@ -1,14 +1,10 @@ // @ts-ignore import { window } from "./tooling/setup"; -import { test } from "uvu"; +import { test, expect } from "bun:test"; import { Event } from "happy-dom"; +import convert from "../src/index"; import { checkEventConversion } from "./tooling/check"; -import { - mockElementObject, - mockGamepad, - mockTouch, - mockTouchObject, -} from "./tooling/mock"; +import { mockGamepad, mockTouch, mockTouchObject } from "./tooling/mock"; type SimpleTestCase = { types: string[]; @@ -255,8 +251,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 +356,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, @@ -378,4 +374,299 @@ test("adds text of current selection", () => { }); }); -test.run(); +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", + }, + }, + }); +}); + +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(); +}); + +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", + }, + }); +}); + +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", + }, + }); +}); + +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"); +}); + +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(); + } +}); 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..835823ad1 100644 --- a/src/js/packages/event-to-object/tests/tooling/check.ts +++ b/src/js/packages/event-to-object/tests/tooling/check.ts @@ -1,4 +1,4 @@ -import * as assert from "uvu/assert"; +import { expect } from "bun:test"; import { Event } from "happy-dom"; // @ts-ignore import lodash from "lodash"; @@ -8,39 +8,130 @@ 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 { + // ignore + } + } + } + + // timeStamp is special + try { + Object.defineProperty(givenEvent, "timeStamp", { + enumerable: true, + value: givenEvent.timeStamp || Date.now(), + writable: true, + configurable: true, + }); + } catch { + // ignore + } + + // 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 { + // ignore + } + } + } + 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", + }, +}); diff --git a/src/reactpy/_console/rewrite_keys.py b/src/reactpy/_console/rewrite_keys.py index 6f7a42f1e..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/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/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/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/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) 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" 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")