5
0
mirror of https://github.com/wailsapp/wails.git synced 2025-05-02 15:11:53 +08:00

Add single listener deregistration (#1969)

* Add single listener deregistration

* Return function to stop listening, updates types

* Add missing returns, improve documentation

* Duplicate interface in go

* Define eventName

* Use lo instead for filtering

* Move logger to Interface. Add sample test.

* Add vite test for events

* Add js test workflow

* Add corresponding go method to remove all events

* Update documentation

Co-authored-by: Lea Anthony <lea.anthony@gmail.com>
This commit is contained in:
Joshua Hull 2022-10-22 22:03:37 +00:00 committed by GitHub
parent 4bff4af2b0
commit 9f751d66e0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 1948 additions and 243 deletions

32
.github/workflows/test_js.yml vendored Normal file
View File

@ -0,0 +1,32 @@
name: Test JS
on:
push:
branches: [ release/*, master ]
workflow_dispatch:
jobs:
test:
name: Run JS Tests
if: github.repository == 'wailsapp/wails'
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [16.x]
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
run: npm install
working-directory: v2/internal/frontend/runtime
- name: Run tests
run: npm test
working-directory: v2/internal/frontend/runtime

View File

@ -1,10 +1,11 @@
package frontend package frontend
type Events interface { type Events interface {
On(eventName string, callback func(...interface{})) On(eventName string, callback func(...interface{})) func()
OnMultiple(eventName string, callback func(...interface{}), counter int) OnMultiple(eventName string, callback func(...interface{}), counter int) func()
Once(eventName string, callback func(...interface{})) Once(eventName string, callback func(...interface{})) func()
Emit(eventName string, data ...interface{}) Emit(eventName string, data ...interface{})
Off(eventName string) Off(eventName string)
OffAll()
Notify(sender Frontend, name string, data ...interface{}) Notify(sender Frontend, name string, data ...interface{})
} }

View File

@ -19,24 +19,26 @@ The electron alternative for Go
class Listener { class Listener {
/** /**
* Creates an instance of Listener. * Creates an instance of Listener.
* @param {string} eventName
* @param {function} callback * @param {function} callback
* @param {number} maxCallbacks * @param {number} maxCallbacks
* @memberof Listener * @memberof Listener
*/ */
constructor(callback, maxCallbacks) { constructor(eventName, callback, maxCallbacks) {
this.eventName = eventName;
// Default of -1 means infinite // Default of -1 means infinite
maxCallbacks = maxCallbacks || -1; this.maxCallbacks = maxCallbacks || -1;
// Callback invokes the callback with the given data // Callback invokes the callback with the given data
// Returns true if this listener should be destroyed // Returns true if this listener should be destroyed
this.Callback = (data) => { this.Callback = (data) => {
callback.apply(null, data); callback.apply(null, data);
// If maxCallbacks is infinite, return false (do not destroy) // If maxCallbacks is infinite, return false (do not destroy)
if (maxCallbacks === -1) { if (this.maxCallbacks === -1) {
return false; return false;
} }
// Decrement maxCallbacks. Return true if now 0, otherwise false // Decrement maxCallbacks. Return true if now 0, otherwise false
maxCallbacks -= 1; this.maxCallbacks -= 1;
return maxCallbacks === 0; return this.maxCallbacks === 0;
}; };
} }
} }
@ -50,11 +52,13 @@ export const eventListeners = {};
* @param {string} eventName * @param {string} eventName
* @param {function} callback * @param {function} callback
* @param {number} maxCallbacks * @param {number} maxCallbacks
* @returns {function} A function to cancel the listener
*/ */
export function EventsOnMultiple(eventName, callback, maxCallbacks) { export function EventsOnMultiple(eventName, callback, maxCallbacks) {
eventListeners[eventName] = eventListeners[eventName] || []; eventListeners[eventName] = eventListeners[eventName] || [];
const thisListener = new Listener(callback, maxCallbacks); const thisListener = new Listener(eventName, callback, maxCallbacks);
eventListeners[eventName].push(thisListener); eventListeners[eventName].push(thisListener);
return () => listenerOff(thisListener);
} }
/** /**
@ -63,9 +67,10 @@ export function EventsOnMultiple(eventName, callback, maxCallbacks) {
* @export * @export
* @param {string} eventName * @param {string} eventName
* @param {function} callback * @param {function} callback
* @returns {function} A function to cancel the listener
*/ */
export function EventsOn(eventName, callback) { export function EventsOn(eventName, callback) {
EventsOnMultiple(eventName, callback, -1); return EventsOnMultiple(eventName, callback, -1);
} }
/** /**
@ -74,9 +79,10 @@ export function EventsOn(eventName, callback) {
* @export * @export
* @param {string} eventName * @param {string} eventName
* @param {function} callback * @param {function} callback
* @returns {function} A function to cancel the listener
*/ */
export function EventsOnce(eventName, callback) { export function EventsOnce(eventName, callback) {
EventsOnMultiple(eventName, callback, 1); return EventsOnMultiple(eventName, callback, 1);
} }
function notifyListeners(eventData) { function notifyListeners(eventData) {
@ -107,7 +113,11 @@ function notifyListeners(eventData) {
} }
// Update callbacks with new list of listeners // Update callbacks with new list of listeners
eventListeners[eventName] = newEventListenerList; if (newEventListenerList.length === 0) {
removeListener(eventName);
} else {
eventListeners[eventName] = newEventListenerList;
}
} }
} }
@ -173,4 +183,30 @@ export function EventsOff(eventName, ...additionalEventNames) {
removeListener(eventName) removeListener(eventName)
}) })
} }
} }
/**
* Off unregisters all event listeners previously registered with On
*/
export function EventsOffAll() {
const eventNames = Object.keys(eventListeners);
for (let i = 0; i !== eventNames.length; i++) {
removeListener(eventNames[i]);
}
}
/**
* listenerOff unregisters a listener previously registered with EventsOn
*
* @param {Listener} listener
*/
function listenerOff(listener) {
const eventName = listener.eventName;
// Remove local listener
eventListeners[eventName] = eventListeners[eventName].filter(l => l !== listener);
// Clean up if there are no event listeners left
if (eventListeners[eventName].length === 0) {
removeListener(eventName);
}
}

View File

@ -0,0 +1,132 @@
import { EventsOnMultiple, EventsNotify, eventListeners, EventsOn, EventsEmit, EventsOffAll, EventsOnce, EventsOff } from './events'
import { expect, describe, it, beforeAll, vi, afterEach, beforeEach } from 'vitest'
// Edit an assertion and save to see HMR in action
beforeAll(() => {
window.WailsInvoke = vi.fn(() => {})
})
afterEach(() => {
EventsOffAll();
vi.resetAllMocks()
})
describe('EventsOnMultiple', () => {
it('should stop after a specified number of times', () => {
const cb = vi.fn()
EventsOnMultiple('a', cb, 5)
EventsNotify(JSON.stringify({name: 'a', data: {}}))
EventsNotify(JSON.stringify({name: 'a', data: {}}))
EventsNotify(JSON.stringify({name: 'a', data: {}}))
EventsNotify(JSON.stringify({name: 'a', data: {}}))
EventsNotify(JSON.stringify({name: 'a', data: {}}))
EventsNotify(JSON.stringify({name: 'a', data: {}}))
expect(cb).toBeCalledTimes(5);
expect(window.WailsInvoke).toBeCalledTimes(1);
expect(window.WailsInvoke).toHaveBeenLastCalledWith('EXa');
})
it('should return a cancel fn', () => {
const cb = vi.fn()
const cancel = EventsOnMultiple('a', cb, 5)
EventsNotify(JSON.stringify({name: 'a', data: {}}))
EventsNotify(JSON.stringify({name: 'a', data: {}}))
cancel()
EventsNotify(JSON.stringify({name: 'a', data: {}}))
EventsNotify(JSON.stringify({name: 'a', data: {}}))
expect(cb).toBeCalledTimes(2)
expect(window.WailsInvoke).toBeCalledTimes(1);
expect(window.WailsInvoke).toHaveBeenLastCalledWith('EXa');
})
})
describe('EventsOn', () => {
it('should create a listener with a count of -1', () => {
EventsOn('a', () => {})
expect(eventListeners['a'][0].maxCallbacks).toBe(-1)
})
it('should return a cancel fn', () => {
const cancel = EventsOn('a', () => {})
cancel();
expect(window.WailsInvoke).toBeCalledTimes(1);
expect(window.WailsInvoke).toHaveBeenLastCalledWith('EXa');
})
})
describe('EventsOnce', () => {
it('should create a listener with a count of 1', () => {
EventsOnce('a', () => {})
expect(eventListeners['a'][0].maxCallbacks).toBe(1)
})
it('should return a cancel fn', () => {
const cancel = EventsOn('a', () => {})
cancel();
expect(window.WailsInvoke).toBeCalledTimes(1);
expect(window.WailsInvoke).toHaveBeenLastCalledWith('EXa');
})
})
describe('EventsNotify', () => {
it('should inform a listener', () => {
const cb = vi.fn()
EventsOn('a', cb)
EventsNotify(JSON.stringify({name: 'a', data: ["one", "two", "three"]}))
expect(cb).toBeCalledTimes(1);
expect(cb).toHaveBeenLastCalledWith("one", "two", "three");
expect(window.WailsInvoke).toBeCalledTimes(0);
})
})
describe('EventsEmit', () => {
it('should emit an event', () => {
EventsEmit('a', 'one', 'two', 'three')
expect(window.WailsInvoke).toBeCalledTimes(1);
const calledWith = window.WailsInvoke.calls[0][0];
expect(calledWith.slice(0, 2)).toBe('EE')
expect(JSON.parse(calledWith.slice(2))).toStrictEqual({data: ["one", "two", "three"], name: "a"})
})
})
describe('EventsOff', () => {
beforeEach(() => {
EventsOn('a', () => {})
EventsOn('a', () => {})
EventsOn('a', () => {})
EventsOn('b', () => {})
EventsOn('c', () => {})
})
it('should cancel all event listeners for a single type', () => {
EventsOff('a')
expect(eventListeners['a']).toBeUndefined()
expect(eventListeners['b']).not.toBeUndefined()
expect(eventListeners['c']).not.toBeUndefined()
expect(window.WailsInvoke).toBeCalledTimes(1);
expect(window.WailsInvoke).toHaveBeenLastCalledWith('EXa');
})
it('should cancel all event listeners for multiple types', () => {
EventsOff('a', 'b')
expect(eventListeners['a']).toBeUndefined()
expect(eventListeners['b']).toBeUndefined()
expect(eventListeners['c']).not.toBeUndefined()
expect(window.WailsInvoke).toBeCalledTimes(2);
expect(window.WailsInvoke.calls).toStrictEqual([['EXa'], ['EXb']]);
})
})
describe('EventsOffAll', () => {
it('should cancel all event listeners', () => {
EventsOn('a', () => {})
EventsOn('a', () => {})
EventsOn('a', () => {})
EventsOn('b', () => {})
EventsOn('c', () => {})
EventsOffAll()
expect(eventListeners).toStrictEqual({})
expect(window.WailsInvoke).toBeCalledTimes(3);
expect(window.WailsInvoke.calls).toStrictEqual([['EXa'], ['EXb'], ['EXc']]);
})
})

View File

@ -1,11 +1,16 @@
package runtime package runtime
import ( import (
"github.com/wailsapp/wails/v2/internal/frontend"
"github.com/wailsapp/wails/v2/internal/logger"
"sync" "sync"
"github.com/samber/lo"
"github.com/wailsapp/wails/v2/internal/frontend"
) )
type Logger interface {
Trace(format string, v ...interface{})
}
// eventListener holds a callback function which is invoked when // eventListener holds a callback function which is invoked when
// the event listened for is emitted. It has a counter which indicates // the event listened for is emitted. It has a counter which indicates
// how the total number of events it is interested in. A value of zero // how the total number of events it is interested in. A value of zero
@ -18,7 +23,7 @@ type eventListener struct {
// Events handles eventing // Events handles eventing
type Events struct { type Events struct {
log *logger.Logger log Logger
frontend []frontend.Frontend frontend []frontend.Frontend
// Go event listeners // Go event listeners
@ -36,16 +41,16 @@ func (e *Events) Notify(sender frontend.Frontend, name string, data ...interface
} }
} }
func (e *Events) On(eventName string, callback func(...interface{})) { func (e *Events) On(eventName string, callback func(...interface{})) func() {
e.registerListener(eventName, callback, -1) return e.registerListener(eventName, callback, -1)
} }
func (e *Events) OnMultiple(eventName string, callback func(...interface{}), counter int) { func (e *Events) OnMultiple(eventName string, callback func(...interface{}), counter int) func() {
e.registerListener(eventName, callback, counter) return e.registerListener(eventName, callback, counter)
} }
func (e *Events) Once(eventName string, callback func(...interface{})) { func (e *Events) Once(eventName string, callback func(...interface{})) func() {
e.registerListener(eventName, callback, 1) return e.registerListener(eventName, callback, 1)
} }
func (e *Events) Emit(eventName string, data ...interface{}) { func (e *Events) Emit(eventName string, data ...interface{}) {
@ -59,8 +64,15 @@ func (e *Events) Off(eventName string) {
e.unRegisterListener(eventName) e.unRegisterListener(eventName)
} }
func (e *Events) OffAll() {
e.notifyLock.Lock()
for eventName := range e.listeners {
delete(e.listeners, eventName)
}
}
// NewEvents creates a new log subsystem // NewEvents creates a new log subsystem
func NewEvents(log *logger.Logger) *Events { func NewEvents(log Logger) *Events {
result := &Events{ result := &Events{
log: log, log: log,
listeners: make(map[string][]*eventListener), listeners: make(map[string][]*eventListener),
@ -69,7 +81,7 @@ func NewEvents(log *logger.Logger) *Events {
} }
// registerListener provides a means of subscribing to events of type "eventName" // registerListener provides a means of subscribing to events of type "eventName"
func (e *Events) registerListener(eventName string, callback func(...interface{}), counter int) { func (e *Events) registerListener(eventName string, callback func(...interface{}), counter int) func() {
// Create new eventListener // Create new eventListener
thisListener := &eventListener{ thisListener := &eventListener{
callback: callback, callback: callback,
@ -80,6 +92,17 @@ func (e *Events) registerListener(eventName string, callback func(...interface{}
// Append the new listener to the listeners slice // Append the new listener to the listeners slice
e.listeners[eventName] = append(e.listeners[eventName], thisListener) e.listeners[eventName] = append(e.listeners[eventName], thisListener)
e.notifyLock.Unlock() e.notifyLock.Unlock()
return func() {
e.notifyLock.Lock()
defer e.notifyLock.Unlock()
if _, ok := e.listeners[eventName]; !ok {
return
}
e.listeners[eventName] = lo.Filter(e.listeners[eventName], func(l *eventListener, i int) bool {
return l != thisListener
})
}
} }
// unRegisterListener provides a means of unsubscribing to events of type "eventName" // unRegisterListener provides a means of unsubscribing to events of type "eventName"

View File

@ -0,0 +1,38 @@
package runtime_test
import (
"fmt"
"github.com/wailsapp/wails/v2/internal/frontend/runtime"
"sync"
"testing"
)
import "github.com/matryer/is"
type mockLogger struct {
Log string
}
func (t *mockLogger) Trace(format string, args ...interface{}) {
t.Log = fmt.Sprintf(format, args...)
}
func Test_EventsOn(t *testing.T) {
i := is.New(t)
l := &mockLogger{}
manager := runtime.NewEvents(l)
// Test On
eventName := "test"
counter := 0
var wg sync.WaitGroup
wg.Add(1)
manager.On(eventName, func(args ...interface{}) {
// This is called in a goroutine
counter++
wg.Done()
})
manager.Emit(eventName, "test payload")
wg.Wait()
i.Equal(1, counter)
}

File diff suppressed because it is too large Load Diff

View File

@ -9,13 +9,15 @@
"build:ipc-dev": "cd dev && npm install && npm run build", "build:ipc-dev": "cd dev && npm install && npm run build",
"build:runtime-desktop-prod": "npx esbuild desktop/main.js --bundle --minify --outfile=runtime_prod_desktop.js --define:ENV=1", "build:runtime-desktop-prod": "npx esbuild desktop/main.js --bundle --minify --outfile=runtime_prod_desktop.js --define:ENV=1",
"build:runtime-desktop-dev": "npx esbuild desktop/main.js --bundle --sourcemap=inline --outfile=runtime_dev_desktop.js --define:ENV=0", "build:runtime-desktop-dev": "npx esbuild desktop/main.js --bundle --sourcemap=inline --outfile=runtime_dev_desktop.js --define:ENV=0",
"test": "echo \"Error: no test specified\" && exit 1" "test": "vitest"
}, },
"author": "Lea Anthony <lea.anthony@gmail.com>", "author": "Lea Anthony <lea.anthony@gmail.com>",
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"esbuild": "^0.15.6", "esbuild": "^0.15.6",
"happy-dom": "^7.6.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"svelte": "^3.49.0" "svelte": "^3.49.0",
"vitest": "^0.24.3"
} }
} }

View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'happy-dom',
},
})

View File

@ -38,20 +38,24 @@ export interface EnvironmentInfo {
export function EventsEmit(eventName: string, ...data: any): void; export function EventsEmit(eventName: string, ...data: any): void;
// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name. // [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
export function EventsOn(eventName: string, callback: (...data: any) => void): void; export function EventsOn(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple) // [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
// sets up a listener for the given event name, but will only trigger a given number times. // sets up a listener for the given event name, but will only trigger a given number times.
export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): void; export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void;
// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce) // [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
// sets up a listener for the given event name, but will only trigger once. // sets up a listener for the given event name, but will only trigger once.
export function EventsOnce(eventName: string, callback: (...data: any) => void): void; export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsff) // [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff)
// unregisters the listener for the given event name. // unregisters the listener for the given event name.
export function EventsOff(eventName: string, ...additionalEventNames: string[]): void; export function EventsOff(eventName: string, ...additionalEventNames: string[]): void;
// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
// unregisters all listeners.
export function EventsOffAll(): void;
// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint) // [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
// logs the given message as a raw message // logs the given message as a raw message
export function LogPrint(message: string): void; export function LogPrint(message: string): void;

View File

@ -4,10 +4,10 @@ import (
"context" "context"
) )
// EventsOn registers a listener for the given event name // EventsOn registers a listener for the given event name. It returns a function to cancel the listener
func EventsOn(ctx context.Context, eventName string, callback func(optionalData ...interface{})) { func EventsOn(ctx context.Context, eventName string, callback func(optionalData ...interface{})) func() {
events := getEvents(ctx) events := getEvents(ctx)
events.On(eventName, callback) return events.On(eventName, callback)
} }
// EventsOff unregisters a listener for the given event name, optionally multiple listeneres can be unregistered via `additionalEventNames` // EventsOff unregisters a listener for the given event name, optionally multiple listeneres can be unregistered via `additionalEventNames`
@ -22,17 +22,24 @@ func EventsOff(ctx context.Context, eventName string, additionalEventNames ...st
} }
} }
// EventsOnce registers a listener for the given event name. After the first callback, the // EventsOff unregisters a listener for the given event name, optionally multiple listeneres can be unregistered via `additionalEventNames`
// listener is deleted. func EventsOffAll(ctx context.Context) {
func EventsOnce(ctx context.Context, eventName string, callback func(optionalData ...interface{})) {
events := getEvents(ctx) events := getEvents(ctx)
events.Once(eventName, callback) events.OffAll()
} }
// EventsOnMultiple registers a listener for the given event name, that may be called a maximum of 'counter' times // EventsOnce registers a listener for the given event name. After the first callback, the
func EventsOnMultiple(ctx context.Context, eventName string, callback func(optionalData ...interface{}), counter int) { // listener is deleted. It returns a function to cancel the listener
func EventsOnce(ctx context.Context, eventName string, callback func(optionalData ...interface{})) func() {
events := getEvents(ctx) events := getEvents(ctx)
events.OnMultiple(eventName, callback, counter) return events.Once(eventName, callback)
}
// EventsOnMultiple registers a listener for the given event name, that may be called a maximum of 'counter' times. It returns a function
// to cancel the listener
func EventsOnMultiple(ctx context.Context, eventName string, callback func(optionalData ...interface{}), counter int) func() {
events := getEvents(ctx)
return events.OnMultiple(eventName, callback, counter)
} }
// EventsEmit pass through // EventsEmit pass through

View File

@ -10,10 +10,11 @@ Optionally, data may be passed with the events. Listeners will receive the data
### EventsOn ### EventsOn
This method sets up a listener for the given event name. When an event of type `eventName` is [emitted](#EventsEmit), This method sets up a listener for the given event name. When an event of type `eventName` is [emitted](#EventsEmit),
the callback is triggered. Any additional data sent with the emitted event will be passed to the callback. the callback is triggered. Any additional data sent with the emitted event will be passed to the callback. It returns
a function to cancel the listener.
Go: `EventsOn(ctx context.Context, eventName string, callback func(optionalData ...interface{}))`<br/> Go: `EventsOn(ctx context.Context, eventName string, callback func(optionalData ...interface{})) func()`<br/>
JS: `EventsOn(eventName string, callback function(optionalData?: any))` JS: `EventsOn(eventName string, callback function(optionalData?: any)): () => void`
### EventsOff ### EventsOff
@ -24,17 +25,19 @@ JS: `EventsOff(eventName string, ...additionalEventNames)`
### EventsOnce ### EventsOnce
This method sets up a listener for the given event name, but will only trigger once. This method sets up a listener for the given event name, but will only trigger once. It returns a function to cancel
the listener.
Go: `EventsOnce(ctx context.Context, eventName string, callback func(optionalData ...interface{}))`<br/> Go: `EventsOnce(ctx context.Context, eventName string, callback func(optionalData ...interface{})) func()`<br/>
JS: `EventsOnce(eventName string, callback function(optionalData?: any))` JS: `EventsOnce(eventName string, callback function(optionalData?: any)): () => void`
### EventsOnMultiple ### EventsOnMultiple
This method sets up a listener for the given event name, but will only trigger a maximum of `counter` times. This method sets up a listener for the given event name, but will only trigger a maximum of `counter` times. It returns
a function to cancel the listener.
Go: `EventsOnMultiple(ctx context.Context, eventName string, callback func(optionalData ...interface{}), counter int)`<br/> Go: `EventsOnMultiple(ctx context.Context, eventName string, callback func(optionalData ...interface{}), counter int) func()`<br/>
JS: `EventsOnMultiple(eventName string, callback function(optionalData?: any), counter int)` JS: `EventsOnMultiple(eventName string, callback function(optionalData?: any), counter int): () => void`
### EventsEmit ### EventsEmit