wails/v3/pkg/application/events.go
Lea Anthony a06d55804c
perf(v3): optimize JSON processing and reduce allocations in hot paths (#4843)
* perf(v3): optimize JSON processing and reduce allocations in hot paths

- Switch to goccy/go-json for method binding, events, and HTTP transport
  (21-63% faster, 40-60% less memory for method calls)
- Optimize BoundMethod struct layout to reduce padding (144 -> 136 bytes)
- Cache isVariadic flag at registration to avoid reflect call per invocation
- Use stack-allocated buffer for method arguments (<=8 args)
- Optimize result collection to avoid slice allocation for single return values
- Use sync.Map for MIME cache to improve concurrent read performance
- Use buffer pool for HTTP transport request body reading
- Lazily allocate CloseNotify channel in content type sniffer
- Remove debug CSS logging from asset server
- Add comprehensive benchmark tests (build tag: bench)

Performance improvements for BoundMethod.Call:
- SimpleCall:   1290ns -> 930ns (28% faster), 240B -> 80B (67% less memory)
- ComplexCall:  10500ns -> 3900ns (63% faster), 1192B -> 1020B (14% less)
- VariadicCall: 3460ns -> 1600ns (54% faster), 512B -> 289B (44% less)

* perf(v3): add max size limit to buffer pool to prevent memory bloat

Buffers larger than 512KB are not returned to the pool, allowing GC
to reclaim memory after large requests (e.g., base64 encoded images).

* perf(v3): remove mimetype library dependency, saving ~208KB binary size

- Replace github.com/wailsapp/mimetype with expanded extension map + stdlib
- Expand MIME type map from 16 to 50+ common web formats (fonts, audio, video, etc.)
- Add comprehensive test suite validating MIME detection for all web formats
- Use http.DetectContentType as fallback for unknown extensions
- Actual binary size reduction: 1.2MB (11MB -> 9.8MB in test app)

* perf(v3): migrate all runtime code to goccy/go-json

Migrate remaining encoding/json usages to goccy/go-json in:
- pkg/application (android, darwin, ios, single_instance, webview_window)
- pkg/services (kvstore, notifications on all platforms)
- internal/assetserver/webview (request/response handling)
- internal/runtime and internal/capabilities

Note: encoding/json (110KB) remains in binary because:
1. goccy/go-json imports it for interface compatibility (json.Marshaler, etc.)
2. log/slog (stdlib) uses it for JSON output

The performance benefit is in the hot paths which now use the faster library.

* perf(v3): replace gopkg.in/ini.v1 with minimal .desktop file parser

Replace the gopkg.in/ini.v1 dependency with a purpose-built minimal parser
for Linux .desktop files.

The new parser:
- Only extracts the Exec key from [Desktop Entry] section (all we need)
- Follows the Desktop Entry Specification
- Has comprehensive test coverage (40 tests) including:
  - All major file managers (Nautilus, Dolphin, Thunar, PCManFM, Caja, Nemo)
  - Edge cases (UTF-8, special chars, comments, empty files, etc.)
  - Buffer limit handling

Binary size reduction: 45KB (10.22MB -> 10.18MB)

* perf(v3): remove samber/lo from runtime code, saving ~310KB binary size

Replace samber/lo with Go 1.21+ stdlib slices package and minimal internal
helpers in all runtime code paths. This removes 80 transitive dependencies
from the production binary.

Changes:
- Create internal/sliceutil package with Unique and FindMapKey helpers
- Replace lo.Without with slices.DeleteFunc in event handling
- Replace lo.Ternary with inline if/else in Windows code
- Replace lo.Uniq with sliceutil.Unique for feature flags
- Replace lo.FindKey with sliceutil.FindMapKey for method aliases
- Replace lo.Filter with slices.DeleteFunc in event listeners
- Replace lo.Must with inline panic in w32 package

Binary size: 10.18MB -> 9.87MB (~310KB / 3% reduction)

Note: CLI tools still use samber/lo since they don't affect
production binary size. The application_debug.go file also
retains lo usage as it has //go:build !production tag.

* fix: address CodeRabbit review comments

- Use application/x-typescript MIME type (not IANA-registered text/typescript)
- Fix potential panic in mimetype_stdlib_test.go for short MIME strings
- Use cached isVariadic flag in bindings_optimized_bench_test.go

* fix: initialize goccy/go-json decoder early to fix Windows test failure

On Windows, goccy/go-json's type address calculation can fail if the
decoder is first invoked during test execution rather than at init time.
Force early initialization by unmarshaling a []int during package init.

See: https://github.com/goccy/go-json/issues/474

* 📝 Add docstrings to `v3/performance-improvements` (#4844)

* fix: initialize goccy/go-json decoder early to fix Windows test failure

On Windows, goccy/go-json's type address calculation can fail if the
decoder is first invoked during test execution rather than at init time.
Force early initialization by unmarshaling a []int during package init.

See: https://github.com/goccy/go-json/issues/474

* 📝 Add docstrings to `v3/performance-improvements`

Docstrings generation was requested by @leaanthony.

* https://github.com/wailsapp/wails/pull/4843#issuecomment-3703472562

The following files were modified:

* `v3/internal/assetserver/common.go`
* `v3/internal/assetserver/content_type_sniffer.go`
* `v3/internal/assetserver/mimecache.go`
* `v3/internal/fileexplorer/desktopfile.go`
* `v3/internal/fileexplorer/fileexplorer_linux.go`
* `v3/internal/sliceutil/sliceutil.go`
* `v3/pkg/application/application_ios.go`
* `v3/pkg/application/bindings.go`
* `v3/pkg/application/ios_runtime_ios.go`
* `v3/pkg/w32/window.go`

---------

Co-authored-by: Lea Anthony <lea.anthony@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-01-02 07:03:36 +11:00

366 lines
9.5 KiB
Go

package application
import (
"fmt"
"reflect"
"slices"
"sync"
"sync/atomic"
json "github.com/goccy/go-json"
"github.com/wailsapp/wails/v3/pkg/events"
)
type ApplicationEvent struct {
Id uint
ctx *ApplicationEventContext
cancelled atomic.Bool
}
func (w *ApplicationEvent) Context() *ApplicationEventContext {
return w.ctx
}
func newApplicationEvent(id events.ApplicationEventType) *ApplicationEvent {
return &ApplicationEvent{
Id: uint(id),
ctx: newApplicationEventContext(),
}
}
func (w *ApplicationEvent) Cancel() {
w.cancelled.Store(true)
}
func (w *ApplicationEvent) IsCancelled() bool {
return w.cancelled.Load()
}
var applicationEvents = make(chan *ApplicationEvent, 5)
type windowEvent struct {
WindowID uint
EventID uint
}
var windowEvents = make(chan *windowEvent, 5)
var menuItemClicked = make(chan uint, 5)
type CustomEvent struct {
Name string `json:"name"`
Data any `json:"data"`
// Sender records the name of the window sending the event,
// or "" if sent from application.
Sender string `json:"sender,omitempty"`
cancelled atomic.Bool
}
func (e *CustomEvent) Cancel() {
e.cancelled.Store(true)
}
func (e *CustomEvent) IsCancelled() bool {
return e.cancelled.Load()
}
func (e *CustomEvent) ToJSON() string {
marshal, err := json.Marshal(&e)
if err != nil {
// TODO: Fatal error? log?
return ""
}
return string(marshal)
}
// WailsEventListener is an interface that can be implemented to listen for Wails events
// It is used by the RegisterListener method of the Application.
type WailsEventListener interface {
DispatchWailsEvent(event *CustomEvent)
}
type hook struct {
callback func(*CustomEvent)
}
// eventListener holds a callback function which is invoked when
// 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
// means it does not expire (default).
type eventListener struct {
callback func(*CustomEvent) // Function to call with emitted event data
counter int // The number of times this callback may be called. -1 = infinite
delete bool // Flag to indicate that this listener should be deleted
}
// EventProcessor handles custom events
type EventProcessor struct {
// Go event listeners
listeners map[string][]*eventListener
notifyLock sync.RWMutex
dispatchEventToWindows func(*CustomEvent)
hooks map[string][]*hook
hookLock sync.RWMutex
}
func NewWailsEventProcessor(dispatchEventToWindows func(*CustomEvent)) *EventProcessor {
return &EventProcessor{
listeners: make(map[string][]*eventListener),
dispatchEventToWindows: dispatchEventToWindows,
hooks: make(map[string][]*hook),
}
}
// On is the equivalent of Javascript's `addEventListener`
func (e *EventProcessor) On(eventName string, callback func(event *CustomEvent)) func() {
return e.registerListener(eventName, callback, -1)
}
// OnMultiple is the same as `OnApplicationEvent` but will unregister after `count` events
func (e *EventProcessor) OnMultiple(eventName string, callback func(event *CustomEvent), counter int) func() {
return e.registerListener(eventName, callback, counter)
}
// Once is the same as `OnApplicationEvent` but will unregister after the first event
func (e *EventProcessor) Once(eventName string, callback func(event *CustomEvent)) func() {
return e.registerListener(eventName, callback, 1)
}
// Emit sends an event to all listeners.
//
// If the event is globally registered, it validates associated data
// against the expected data type. In case of mismatches,
// it cancels the event and returns an error.
func (e *EventProcessor) Emit(thisEvent *CustomEvent) error {
if thisEvent == nil {
return nil
}
// Validate data type; in case of mismatches cancel and report error.
if err := validateCustomEvent(thisEvent); err != nil {
thisEvent.Cancel()
return err
}
// If we have any hooks, run them first and check if the event was cancelled
if e.hooks != nil {
if hooks, ok := e.hooks[thisEvent.Name]; ok {
for _, thisHook := range hooks {
thisHook.callback(thisEvent)
if thisEvent.IsCancelled() {
return nil
}
}
}
}
go func() {
defer handlePanic()
e.dispatchEventToListeners(thisEvent)
}()
go func() {
defer handlePanic()
e.dispatchEventToWindows(thisEvent)
}()
return nil
}
func (e *EventProcessor) Off(eventName string) {
e.unRegisterListener(eventName)
}
func (e *EventProcessor) OffAll() {
e.notifyLock.Lock()
defer e.notifyLock.Unlock()
e.listeners = make(map[string][]*eventListener)
}
// registerListener provides a means of subscribing to events of type "eventName"
func (e *EventProcessor) registerListener(eventName string, callback func(*CustomEvent), counter int) func() {
// Create new eventListener
thisListener := &eventListener{
callback: callback,
counter: counter,
delete: false,
}
e.notifyLock.Lock()
// Append the new listener to the listeners slice
e.listeners[eventName] = append(e.listeners[eventName], thisListener)
e.notifyLock.Unlock()
return func() {
e.notifyLock.Lock()
defer e.notifyLock.Unlock()
if _, ok := e.listeners[eventName]; !ok {
return
}
e.listeners[eventName] = slices.DeleteFunc(e.listeners[eventName], func(l *eventListener) bool {
return l == thisListener
})
}
}
// RegisterHook provides a means of registering methods to be called before emitting the event
func (e *EventProcessor) RegisterHook(eventName string, callback func(*CustomEvent)) func() {
// Create new hook
thisHook := &hook{
callback: callback,
}
e.hookLock.Lock()
// Append the new listener to the listeners slice
e.hooks[eventName] = append(e.hooks[eventName], thisHook)
e.hookLock.Unlock()
return func() {
e.hookLock.Lock()
defer e.hookLock.Unlock()
if _, ok := e.hooks[eventName]; !ok {
return
}
e.hooks[eventName] = slices.DeleteFunc(e.hooks[eventName], func(h *hook) bool {
return h == thisHook
})
}
}
// unRegisterListener provides a means of unsubscribing to events of type "eventName"
func (e *EventProcessor) unRegisterListener(eventName string) {
e.notifyLock.Lock()
defer e.notifyLock.Unlock()
delete(e.listeners, eventName)
}
// dispatchEventToListeners calls all registered listeners event name
func (e *EventProcessor) dispatchEventToListeners(event *CustomEvent) {
e.notifyLock.Lock()
defer e.notifyLock.Unlock()
listeners := e.listeners[event.Name]
if listeners == nil {
return
}
// We have a dirty flag to indicate that there are items to delete
itemsToDelete := false
// Callback in goroutine
for _, listener := range listeners {
if listener.counter > 0 {
listener.counter--
}
go func() {
if event.IsCancelled() {
return
}
defer handlePanic()
listener.callback(event)
}()
if listener.counter == 0 {
listener.delete = true
itemsToDelete = true
}
}
// Do we have items to delete?
if itemsToDelete == true {
e.listeners[event.Name] = slices.DeleteFunc(listeners, func(l *eventListener) bool {
return l.delete == true
})
}
}
// Void will be translated by the binding generator to the TypeScript type 'void'.
// It can be used as an event data type to register events that must not have any associated data.
type Void interface {
sentinel()
}
var registeredEvents sync.Map
var voidType = reflect.TypeFor[Void]()
// RegisterEvent registers a custom event name and associated data type.
// Events may be registered at most once.
// Duplicate calls for the same event name trigger a panic.
//
// The binding generator emits typing information for all registered custom events.
// [App.EmitEvent] and [Window.EmitEvent] check the data type for registered events.
// Data types are matched exactly and no conversion is performed.
//
// It is recommended to call RegisterEvent directly,
// with constant arguments, and only from init functions.
// Indirect calls or instantiations are not discoverable by the binding generator.
func RegisterEvent[Data any](name string) {
if events.IsKnownEvent(name) {
panic(fmt.Errorf("'%s' is a known system event name", name))
}
if typ, ok := registeredEvents.Load(name); ok {
panic(fmt.Errorf("event '%s' is already registered with data type %s", name, typ))
}
registeredEvents.Store(name, reflect.TypeFor[Data]())
eventRegistered(name)
}
func validateCustomEvent(event *CustomEvent) error {
r, ok := registeredEvents.Load(event.Name)
if !ok {
warnAboutUnregisteredEvent(event.Name)
return nil
}
typ := r.(reflect.Type)
if typ == voidType {
if event.Data == nil {
return nil
}
} else if typ.Kind() == reflect.Interface {
if reflect.TypeOf(event.Data).Implements(typ) {
return nil
}
} else {
if reflect.TypeOf(event.Data) == typ {
return nil
}
}
return fmt.Errorf(
"data of type %s for event '%s' does not match registered data type %s",
reflect.TypeOf(event.Data),
event.Name,
typ,
)
}
func decodeEventData(name string, data []byte) (result any, err error) {
r, ok := registeredEvents.Load(name)
if !ok {
// Unregistered events unmarshal to any.
err = json.Unmarshal(data, &result)
return
}
typ := r.(reflect.Type)
if typ == voidType {
// When typ is voidType, perform a null check
err = json.Unmarshal(data, &result)
if err == nil && result != nil {
err = fmt.Errorf("non-null data for event '%s' does not match registered data type %s", name, typ)
}
} else {
value := reflect.New(typ.(reflect.Type))
err = json.Unmarshal(data, value.Interface())
if err == nil {
result = value.Elem().Interface()
}
}
return
}