Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ FROM node:24-alpine AS frontend
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
# patchedDependencies in pnpm-workspace.yaml are applied during install.
COPY patches/ ./patches/
RUN pnpm install --frozen-lockfile
COPY src/ ./src/
COPY public/ ./public/
Expand Down
164 changes: 164 additions & 0 deletions patches/@tauri-apps__api@2.12.4.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
diff --git a/event.cjs b/event.cjs
index b53f3497d826d26e6182e28781b519e6e14f39c8..c926acf081427b7c79bcf170c0028a18fceda786 100644
--- a/event.cjs
+++ b/event.cjs
@@ -99,7 +99,13 @@ exports.TauriEvent = void 0;
* @returns
*/
async function _unlisten(event, eventId) {
- window.__TAURI_EVENT_PLUGIN_INTERNALS__.unregisterListener(event, eventId);
+ try {
+ window.__TAURI_EVENT_PLUGIN_INTERNALS__?.unregisterListener(event, eventId);
+ }
+ catch {
+ // Native registration can complete before its JavaScript registry eval.
+ // The captured callback has already been released by _listen.
+ }
await core.invoke('plugin:event|unlisten', {
event,
eventId
@@ -134,17 +140,44 @@ async function _unlisten(event, eventId) {
* @since 1.0.0
*/
async function listen(event, handler, options) {
+ return _listen(event, handler, options, false);
+}
+async function _listen(event, handler, options, onlyOnce) {
var _a;
const target = typeof (options === null || options === void 0 ? void 0 : options.target) === 'string'
? { kind: 'AnyLabel', label: options.target }
: ((_a = options === null || options === void 0 ? void 0 : options.target) !== null && _a !== void 0 ? _a : { kind: 'Any' });
- return core.invoke('plugin:event|listen', {
- event,
- target,
- handler: core.transformCallback(handler)
- }).then((eventId) => {
- return async () => _unlisten(event, eventId);
+ let disposed = false;
+ const releaseCallback = () => {
+ window.__TAURI_INTERNALS__.unregisterCallback?.(handlerId);
+ };
+ const cleanup = async (eventId) => {
+ if (disposed)
+ return;
+ disposed = true;
+ releaseCallback();
+ await _unlisten(event, eventId);
+ };
+ const handlerId = core.transformCallback((eventData) => {
+ if (disposed)
+ return;
+ if (onlyOnce)
+ void cleanup(eventData.id).catch(() => { });
+ handler(eventData);
});
+ try {
+ const eventId = await core.invoke('plugin:event|listen', {
+ event,
+ target,
+ handler: handlerId
+ });
+ return async () => cleanup(eventId);
+ }
+ catch (error) {
+ disposed = true;
+ releaseCallback();
+ throw error;
+ }
}
/**
* Listens once to an emitted event to any {@link EventTarget|target}.
@@ -177,10 +210,7 @@ async function listen(event, handler, options) {
* @since 1.0.0
*/
async function once(event, handler, options) {
- return listen(event, (eventData) => {
- void _unlisten(event, eventData.id);
- handler(eventData);
- }, options);
+ return _listen(event, handler, options, true);
}
/**
* Emits an event to all {@link EventTarget|targets}.
diff --git a/event.js b/event.js
index 1e03752962d8cb5edad90d59732e486e187bee5b..3a3ebc94e4d5a58a95f1529a031615d162946f03 100644
--- a/event.js
+++ b/event.js
@@ -97,7 +97,13 @@ var TauriEvent;
* @returns
*/
async function _unlisten(event, eventId) {
- window.__TAURI_EVENT_PLUGIN_INTERNALS__.unregisterListener(event, eventId);
+ try {
+ window.__TAURI_EVENT_PLUGIN_INTERNALS__?.unregisterListener(event, eventId);
+ }
+ catch {
+ // Native registration can complete before its JavaScript registry eval.
+ // The captured callback has already been released by _listen.
+ }
await invoke('plugin:event|unlisten', {
event,
eventId
@@ -132,17 +138,44 @@ async function _unlisten(event, eventId) {
* @since 1.0.0
*/
async function listen(event, handler, options) {
+ return _listen(event, handler, options, false);
+}
+async function _listen(event, handler, options, onlyOnce) {
var _a;
const target = typeof (options === null || options === void 0 ? void 0 : options.target) === 'string'
? { kind: 'AnyLabel', label: options.target }
: ((_a = options === null || options === void 0 ? void 0 : options.target) !== null && _a !== void 0 ? _a : { kind: 'Any' });
- return invoke('plugin:event|listen', {
- event,
- target,
- handler: transformCallback(handler)
- }).then((eventId) => {
- return async () => _unlisten(event, eventId);
+ let disposed = false;
+ const releaseCallback = () => {
+ window.__TAURI_INTERNALS__.unregisterCallback?.(handlerId);
+ };
+ const cleanup = async (eventId) => {
+ if (disposed)
+ return;
+ disposed = true;
+ releaseCallback();
+ await _unlisten(event, eventId);
+ };
+ const handlerId = transformCallback((eventData) => {
+ if (disposed)
+ return;
+ if (onlyOnce)
+ void cleanup(eventData.id).catch(() => { });
+ handler(eventData);
});
+ try {
+ const eventId = await invoke('plugin:event|listen', {
+ event,
+ target,
+ handler: handlerId
+ });
+ return async () => cleanup(eventId);
+ }
+ catch (error) {
+ disposed = true;
+ releaseCallback();
+ throw error;
+ }
}
/**
* Listens once to an emitted event to any {@link EventTarget|target}.
@@ -175,10 +208,7 @@ async function listen(event, handler, options) {
* @since 1.0.0
*/
async function once(event, handler, options) {
- return listen(event, (eventData) => {
- void _unlisten(event, eventData.id);
- handler(eventData);
- }, options);
+ return _listen(event, handler, options, true);
}
/**
* Emits an event to all {@link EventTarget|targets}.
25 changes: 25 additions & 0 deletions patches/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Tauri API 2.12.4 event callback cleanup

Tauri creates a JavaScript callback before awaiting native listener registration.
Its native registration eval and invoke response can reach WKWebView in either
order. If cleanup runs before that eval, the event registry does not yet contain
the callback ID. Registration failures never populate it at all. Both cases can
retain the subscriber closure after its view has gone away.

The patch captures the callback ID at registration, releases it explicitly on
failure or cleanup, and still calls native unlisten when registry cleanup is
unavailable. Cleanup is idempotent. `once` releases before invoking user code,
including when the first event arrives before the registration response.
Both `event.js` and `event.cjs` are patched; global, Webview, and Window listeners
all reach these functions. The transport, which registers through the core API,
owns the same cleanup directly.

The lockfile records the package patch hash. Run a frozen install and
`src/lib/tauri-native-event-memory.test.ts` against the installed ESM/CommonJS
package, plus `src/lib/transport/tauri-transport.test.ts`. They cover registration
failure, cleanup racing the registration eval, repeated mounts, normal independent
subscriptions, registry exceptions, and early `once` events. Run full TypeScript
checking as well, including tests rather than just Next's production build.

Revisit this patch on upgrades to `@tauri-apps/api` or after upstream provides an
equivalent callback lifetime fix. It does not change the Rust Tauri event plugin.
17 changes: 10 additions & 7 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,6 @@ minimumReleaseAgeExclude:
- '@tauri-apps/plugin-dialog@2.8.3'
- '@tauri-apps/plugin-opener@2.7.2'
- '@tauri-apps/plugin-updater@2.14.0'

patchedDependencies:
'@tauri-apps/api@2.12.4': patches/@tauri-apps__api@2.12.4.patch
Loading