Skip to content

Commit

Permalink
Try another approach to typing event target
Browse files Browse the repository at this point in the history
Compiles but untried
Forces us to remove the clean up in dispose so perhaps some kind of
assertion should replace it.
  • Loading branch information
microbit-matt-hillsdon committed Jul 4, 2024
1 parent eded0a4 commit 2c2e7cf
Show file tree
Hide file tree
Showing 22 changed files with 405 additions and 154 deletions.
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@
"crelt": "^1.0.5",
"dapjs": "2.2.0",
"dompurify": "^2.3.3",
"events": "^3.3.0",
"file-saver": "^2.0.5",
"framer-motion": "^10.2.4",
"lodash.debounce": "^4.0.8",
Expand Down
122 changes: 122 additions & 0 deletions src/common/events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* Copyright (c) 2022 Jonas "DerZade" Schade
*
* SPDX-License-Identifier: MIT
*
* https://github.com/DerZade/typescript-event-target/blob/master/src/TypedEventTarget.ts
*/

/**
* A function that can be passed to the `listener` parameter of {@link TypedEventTarget.addEventListener} and {@link TypedEventTarget.removeEventListener}.
*
* @template M A map of event types to their respective event classes.
* @template T The type of event to listen for (has to be keyof `M`).
*/
export type TypedEventListener<M, T extends keyof M> = (
evt: M[T]
) => void | Promise<void>;

/**
* An object that can be passed to the `listener` parameter of {@link TypedEventTarget.addEventListener} and {@link TypedEventTarget.removeEventListener}.
*
* @template M A map of event types to their respective event classes.
* @template T The type of event to listen for (has to be keyof `M`).
*/
export interface TypedEventListenerObject<M, T extends keyof M> {
handleEvent: (evt: M[T]) => void | Promise<void>;
}

/**
* Type of parameter `listener` in {@link TypedEventTarget.addEventListener} and {@link TypedEventTarget.removeEventListener}.
*
* The object that receives a notification (an object that implements the Event interface) when an event of the specified type occurs.
*
* Can be either an object with a handleEvent() method, or a JavaScript function.
*
* @template M A map of event types to their respective event classes.
* @template T The type of event to listen for (has to be keyof `M`).
*/
export type TypedEventListenerOrEventListenerObject<M, T extends keyof M> =
| TypedEventListener<M, T>
| TypedEventListenerObject<M, T>;

type ValueIsEvent<T> = {
[key in keyof T]: Event;
};

/**
* Typescript friendly version of {@link EventTarget}
*
* @template M A map of event types to their respective event classes.
*
* @example
* ```typescript
* interface MyEventMap {
* hello: Event;
* time: CustomEvent<number>;
* }
*
* const eventTarget = new TypedEventTarget<MyEventMap>();
*
* eventTarget.addEventListener('time', (event) => {
* // event is of type CustomEvent<number>
* });
* ```
*/
export interface TypedEventTarget<M extends ValueIsEvent<M>> {
/** Appends an event listener for events whose type attribute value is type.
* The callback argument sets the callback that will be invoked when the event
* is dispatched.
*
* The options argument sets listener-specific options. For compatibility this
* can be a boolean, in which case the method behaves exactly as if the value
* was specified as options's capture.
*
* When set to true, options's capture prevents callback from being invoked
* when the event's eventPhase attribute value is BUBBLING_PHASE. When false
* (or not present), callback will not be invoked when event's eventPhase
* attribute value is CAPTURING_PHASE. Either way, callback will be invoked if
* event's eventPhase attribute value is AT_TARGET.
*
* When set to true, options's passive indicates that the callback will not
* cancel the event by invoking preventDefault(). This is used to enable
* performance optimizations described in § 2.8 Observing event listeners.
*
* When set to true, options's once indicates that the callback will only be
* invoked once after which the event listener will be removed.
*
* The event listener is appended to target's event listener list and is not
* appended if it has the same type, callback, and capture. */
addEventListener: <T extends keyof M & string>(
type: T,
listener: TypedEventListenerOrEventListenerObject<M, T> | null,
options?: boolean | AddEventListenerOptions
) => void;

/** Removes the event listener in target's event listener list with the same
* type, callback, and options. */
removeEventListener: <T extends keyof M & string>(
type: T,
callback: TypedEventListenerOrEventListenerObject<M, T> | null,
options?: EventListenerOptions | boolean
) => void;

/**
* Dispatches a synthetic event event to target and returns true if either
* event's cancelable attribute value is false or its preventDefault() method
* was not invoked, and false otherwise.
* @deprecated To ensure type safety use `dispatchTypedEvent` instead.
*/
dispatchEvent: (event: Event) => boolean;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export class TypedEventTarget<M extends ValueIsEvent<M>> extends EventTarget {
/**
* Dispatches a synthetic event event to target and returns true if either
* event's cancelable attribute value is false or its preventDefault() method
* was not invoked, and false otherwise.
*/
public dispatchTypedEvent<T extends keyof M>(_type: T, event: M[T]): boolean {
return super.dispatchEvent(event);
}
}
43 changes: 22 additions & 21 deletions src/device/device-hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import React, {
useEffect,
useState,
} from "react";
import { EVENT_PROJECT_UPDATED, EVENT_TEXT_EDIT } from "../fs/fs";
import { useFileSystem } from "../fs/fs-hooks";
import { useLogging } from "../logging/logging-hooks";
import {
Expand All @@ -22,6 +21,8 @@ import {
EVENT_SERIAL_ERROR,
EVENT_SERIAL_RESET,
EVENT_STATUS,
SerialDataEvent,
StatusEvent,
} from "./device";
import { SimulatorDeviceConnection } from "./simulator";

Expand Down Expand Up @@ -60,12 +61,12 @@ export const useConnectionStatus = () => {
const device = useDevice();
const [status, setStatus] = useState<ConnectionStatus>(device.status);
useEffect(() => {
const statusListener = (status: ConnectionStatus) => {
setStatus(status);
const statusListener = (event: StatusEvent) => {
setStatus(event.status);
};
device.on(EVENT_STATUS, statusListener);
device.addEventListener(EVENT_STATUS, statusListener);
return () => {
device.removeListener(EVENT_STATUS, statusListener);
device.removeEventListener(EVENT_STATUS, statusListener);
};
}, [device, setStatus]);

Expand Down Expand Up @@ -189,8 +190,8 @@ export const useDeviceTraceback = () => {

useEffect(() => {
const buffer = new TracebackScrollback();
const dataListener = (data: string) => {
const latest = buffer.push(data);
const dataListener = (event: SerialDataEvent) => {
const latest = buffer.push(event.data);
setRuntimeError((current) => {
if (!current && latest) {
logging.event({
Expand All @@ -204,13 +205,13 @@ export const useDeviceTraceback = () => {
buffer.clear();
setRuntimeError(undefined);
};
device.addListener(EVENT_SERIAL_DATA, dataListener);
device.addListener(EVENT_SERIAL_RESET, clearListener);
device.addListener(EVENT_SERIAL_ERROR, clearListener);
device.addEventListener(EVENT_SERIAL_DATA, dataListener);
device.addEventListener(EVENT_SERIAL_RESET, clearListener);
device.addEventListener(EVENT_SERIAL_ERROR, clearListener);
return () => {
device.removeListener(EVENT_SERIAL_ERROR, clearListener);
device.removeListener(EVENT_SERIAL_RESET, clearListener);
device.removeListener(EVENT_SERIAL_DATA, dataListener);
device.removeEventListener(EVENT_SERIAL_ERROR, clearListener);
device.removeEventListener(EVENT_SERIAL_RESET, clearListener);
device.removeEventListener(EVENT_SERIAL_DATA, dataListener);
};
}, [device, setRuntimeError, logging]);

Expand Down Expand Up @@ -247,15 +248,15 @@ export const DeviceContextProvider = ({
useEffect(() => {
const moveToOutOfSync = () => setSyncStatus(SyncStatus.OUT_OF_SYNC);
const moveToInSync = () => setSyncStatus(SyncStatus.IN_SYNC);
fs.on(EVENT_TEXT_EDIT, moveToOutOfSync);
fs.on(EVENT_PROJECT_UPDATED, moveToOutOfSync);
device.on(EVENT_FLASH, moveToInSync);
device.on(EVENT_STATUS, moveToOutOfSync);
fs.addEventListener("file_text_updated", moveToOutOfSync);
fs.addEventListener("project_updated", moveToOutOfSync);
device.addEventListener(EVENT_FLASH, moveToInSync);
device.addEventListener(EVENT_STATUS, moveToOutOfSync);
return () => {
fs.removeListener(EVENT_TEXT_EDIT, moveToOutOfSync);
fs.removeListener(EVENT_PROJECT_UPDATED, moveToOutOfSync);
device.removeListener(EVENT_STATUS, moveToOutOfSync);
device.removeListener(EVENT_FLASH, moveToInSync);
fs.removeEventListener("file_text_updated", moveToOutOfSync);
fs.removeEventListener("project_updated", moveToOutOfSync);
device.removeEventListener(EVENT_STATUS, moveToOutOfSync);
device.removeEventListener(EVENT_FLASH, moveToInSync);
};
}, [fs, device, setSyncStatus]);
return (
Expand Down
57 changes: 55 additions & 2 deletions src/device/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*
* SPDX-License-Identifier: MIT
*/
import EventEmitter from "events";
import { TypedEventTarget } from "../common/events";
import { Logging } from "../logging/logging";
import { BoardId } from "./board-id";

Expand Down Expand Up @@ -135,7 +135,60 @@ export interface ConnectOptions {

export type BoardVersion = "V1" | "V2";

export interface DeviceConnection extends EventEmitter {
export class StatusEvent extends Event {
constructor(public readonly status: ConnectionStatus) {
super("status");
}
}

export class SerialDataEvent extends Event {
constructor(public readonly data: string) {
super("serial_data");
}
}

export class SerialResetEvent extends Event {
constructor() {
super("serial_reset");
}
}

export class SerialErrorEvent extends Event {
constructor(public readonly error: unknown) {
super("serial_error");
}
}

export class FlashEvent extends Event {
constructor() {
super("flash");
}
}

export class StartUSBSelect extends Event {
constructor() {
super("start_usb_select");
}
}

export class EndUSBSelect extends Event {
constructor() {
super("start_usb_select");
}
}

export class DeviceConnectionEventMap {
"status": StatusEvent;
"serial_data": SerialDataEvent;
"serial_reset": Event;
"serial_error": Event;
"flash": Event;
"start_usb_select": Event;
"end_usb_select": Event;
}

export interface DeviceConnection
extends TypedEventTarget<DeviceConnectionEventMap> {
status: ConnectionStatus;

/**
Expand Down
21 changes: 10 additions & 11 deletions src/device/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,19 @@
*
* SPDX-License-Identifier: MIT
*/
import { TypedEventTarget } from "../common/events";
import {
BoardVersion,
ConnectionStatus,
DeviceConnection,
EVENT_FLASH,
EVENT_SERIAL_DATA,
EVENT_STATUS,
DeviceConnectionEventMap,
FlashDataSource,
FlashEvent,
SerialDataEvent,
StatusEvent,
WebUSBError,
WebUSBErrorCode,
} from "./device";
import EventEmitter from "events";

/**
* A mock device used during end-to-end testing.
Expand All @@ -24,7 +25,7 @@ import EventEmitter from "events";
* the connected state without a real device.
*/
export class MockDeviceConnection
extends EventEmitter
extends TypedEventTarget<DeviceConnectionEventMap>
implements DeviceConnection
{
status: ConnectionStatus = navigator.usb
Expand All @@ -40,7 +41,7 @@ export class MockDeviceConnection
}

mockSerialWrite(data: string) {
this.emit(EVENT_SERIAL_DATA, data);
this.dispatchTypedEvent("serial_data", new SerialDataEvent(data));
}

mockConnect(code: WebUSBErrorCode) {
Expand All @@ -49,9 +50,7 @@ export class MockDeviceConnection

async initialize(): Promise<void> {}

dispose() {
this.removeAllListeners();
}
dispose() {}

async connect(): Promise<ConnectionStatus> {
const next = this.connectResults.shift();
Expand Down Expand Up @@ -90,7 +89,7 @@ export class MockDeviceConnection
options.progress(0.5);
await new Promise((resolve) => setTimeout(resolve, 100));
options.progress(undefined);
this.emit(EVENT_FLASH);
this.dispatchTypedEvent("flash", new FlashEvent());
}

async disconnect(): Promise<void> {
Expand All @@ -103,7 +102,7 @@ export class MockDeviceConnection

private setStatus(newStatus: ConnectionStatus) {
this.status = newStatus;
this.emit(EVENT_STATUS, this.status);
this.dispatchTypedEvent("status", new StatusEvent(this.status));
}

clearDevice(): void {
Expand Down
Loading

0 comments on commit 2c2e7cf

Please sign in to comment.