Plugin proxy is unintentionally Thenable: get-trap default returns function wrapper for 'then' / 'catch' / 'finally'
Labels (suggested): bug, core, needs review
Summary
The plugin proxy created by registerPlugin() in core/src/runtime.ts:162-181 is unintentionally a JavaScript Thenable. Its get trap returns createPluginMethodWrapper(prop) for any property name not in the switch — including then, catch, and finally. Per ECMAScript Promise spec, any object with a callable .then is treated as a Thenable, causing surprising behavior when a plugin proxy passes through Promise resolution.
Mechanism
core/src/runtime.ts:165-178 (verified against main branch):
get(_, prop) {
switch (prop) {
// https://github.com/facebook/react/issues/20030
case '$$typeof':
return undefined;
case 'toJSON':
return () => ({});
case 'addListener':
return pluginHeader ? addListenerNative : addListener;
case 'removeListener':
return removeListener;
default:
return createPluginMethodWrapper(prop);
}
},
When code holds a plugin proxy as a Promise resolution value, the JS engine's Thenable check fires:
- Consumer code:
const pluginPromise = import("some-capacitor-plugin").then((module) => module.SomePlugin); — common dynamic-import pattern for lazy plugin loading
- The
.then((module) => module.SomePlugin) callback returns the plugin proxy
- Promise resolution machinery checks: is this returned value a Thenable? Reads
.then on the proxy
- Proxy get-trap returns
createPluginMethodWrapper("then") — a function — JS engine treats proxy as Thenable
- Promise resolution invokes
proxy.then(resolve, reject) to chain
- The wrapper dispatches
cap.nativePromise(pluginName, "then", resolve) to the native bridge
- No native plugin has
@PluginMethod for then → bridge rejects with "PluginName.then() is not implemented on android" (or platform-equivalent)
- The outer
Promise.resolve(proxy) may hang (resolve/reject never invoked by the wrapper) AND/OR the inner cap.nativePromise rejection bubbles to window.unhandledrejection
The // https://github.com/facebook/react/issues/20030 comment at line 167 already acknowledges that proxies-passed-into-framework-machinery need explicit short-circuits (React's $$typeof check). The same issue applies to Promise machinery's .then/.catch/.finally checks — these need to return undefined for the same architectural reason.
Affected versions (empirically verified)
@capacitor/core@8.3.1 (confirmed via dist/index.js:156-172)
@capacitor/core@9.0.0-alpha.2 (confirmed via dist/index.js:159-167 — same proxy shape)
main branch as of fetch (confirmed via core/src/runtime.ts:165-178)
Bug appears unchanged from at least 8.x through 9.0.0-alpha; likely present in 7.x and earlier.
Real-world failure surfaces
In a private game-runtime codebase using Capacitor 8.3.1 + multiple community plugins, we hit three production-surface symptoms of this bug, all sharing the same root cause:
await adMob.prepareForAds() race-throw: AdMob plugin proxy used via dynamic-import-then pattern; sometimes the await sees "AdMob.then() is not implemented on android" rejection before the actual prepareForAds() invocation. Timing-dependent due to cap.nativePromise dispatch race.
await iap.initialize() hang: IAP plugin proxy (different community plugin); same dynamic-import-then pattern; the Promise.resolve(proxy) chain calls proxy.then(resolve, reject) which never resolves the outer promise → hangs forever.
await createMonetizationBridge(...) hang: an async factory function whose internal addListener calls on the IAP proxy trigger the same bug at a different call surface.
We shipped a consumer-side workaround pattern (container-wrap):
// Before (buggy):
let pluginPromise: Promise<PluginType> | undefined;
const loadPlugin = (): Promise<PluginType> => {
pluginPromise ??= import("some-plugin").then((m) => m.Plugin);
return pluginPromise;
};
// After (workaround; defeats the Thenable check):
let pluginPromise: Promise<{ plugin: PluginType }> | undefined;
const loadPlugin = (): Promise<{ plugin: PluginType }> => {
pluginPromise ??= import("some-plugin").then((m) => ({ plugin: m.Plugin }));
return pluginPromise;
};
// Consumer:
const { plugin } = await loadPlugin();
await plugin.someMethod();
The container-wrap works because plain objects without a .then property are not Thenable. But this is fragile (every consumer-side dynamic-import must remember to wrap; a single oversight reintroduces the bug class).
Proposed fix
Add then, catch, and finally to the get-trap switch returning undefined:
get(_, prop) {
switch (prop) {
// https://github.com/facebook/react/issues/20030
case '$$typeof':
return undefined;
case 'toJSON':
return () => ({});
case 'addListener':
return pluginHeader ? addListenerNative : addListener;
case 'removeListener':
return removeListener;
// Promise-machinery short-circuit. See #<issue-number> for failure modes.
case 'then':
case 'catch':
case 'finally':
return undefined;
default:
return createPluginMethodWrapper(prop);
}
},
Matches the architectural precedent of $$typeof (React framework-machinery short-circuit). Zero runtime behavior change for any code that doesn't accidentally feed the proxy through Promise resolution; eliminates the latent footgun for code that does.
Plugin proxy is unintentionally Thenable: get-trap default returns function wrapper for 'then' / 'catch' / 'finally'
Labels (suggested):
bug,core,needs reviewSummary
The plugin proxy created by
registerPlugin()incore/src/runtime.ts:162-181is unintentionally a JavaScript Thenable. Itsgettrap returnscreatePluginMethodWrapper(prop)for any property name not in the switch — includingthen,catch, andfinally. Per ECMAScript Promise spec, any object with a callable.thenis treated as a Thenable, causing surprising behavior when a plugin proxy passes through Promise resolution.Mechanism
core/src/runtime.ts:165-178(verified against main branch):When code holds a plugin proxy as a Promise resolution value, the JS engine's Thenable check fires:
const pluginPromise = import("some-capacitor-plugin").then((module) => module.SomePlugin);— common dynamic-import pattern for lazy plugin loading.then((module) => module.SomePlugin)callback returns the plugin proxy.thenon the proxycreatePluginMethodWrapper("then")— a function — JS engine treats proxy as Thenableproxy.then(resolve, reject)to chaincap.nativePromise(pluginName, "then", resolve)to the native bridge@PluginMethodforthen→ bridge rejects with"PluginName.then() is not implemented on android"(or platform-equivalent)Promise.resolve(proxy)may hang (resolve/reject never invoked by the wrapper) AND/OR the inner cap.nativePromise rejection bubbles towindow.unhandledrejectionThe
// https://github.com/facebook/react/issues/20030comment at line 167 already acknowledges that proxies-passed-into-framework-machinery need explicit short-circuits (React's$$typeofcheck). The same issue applies to Promise machinery's.then/.catch/.finallychecks — these need to returnundefinedfor the same architectural reason.Affected versions (empirically verified)
@capacitor/core@8.3.1(confirmed viadist/index.js:156-172)@capacitor/core@9.0.0-alpha.2(confirmed viadist/index.js:159-167— same proxy shape)mainbranch as of fetch (confirmed viacore/src/runtime.ts:165-178)Bug appears unchanged from at least 8.x through 9.0.0-alpha; likely present in 7.x and earlier.
Real-world failure surfaces
In a private game-runtime codebase using Capacitor 8.3.1 + multiple community plugins, we hit three production-surface symptoms of this bug, all sharing the same root cause:
await adMob.prepareForAds()race-throw: AdMob plugin proxy used via dynamic-import-then pattern; sometimes the await sees"AdMob.then() is not implemented on android"rejection before the actualprepareForAds()invocation. Timing-dependent due tocap.nativePromisedispatch race.await iap.initialize()hang: IAP plugin proxy (different community plugin); same dynamic-import-then pattern; thePromise.resolve(proxy)chain callsproxy.then(resolve, reject)which never resolves the outer promise → hangs forever.await createMonetizationBridge(...)hang: an async factory function whose internaladdListenercalls on the IAP proxy trigger the same bug at a different call surface.We shipped a consumer-side workaround pattern (container-wrap):
The container-wrap works because plain objects without a
.thenproperty are not Thenable. But this is fragile (every consumer-side dynamic-import must remember to wrap; a single oversight reintroduces the bug class).Proposed fix
Add
then,catch, andfinallyto the get-trap switch returningundefined:Matches the architectural precedent of
$$typeof(React framework-machinery short-circuit). Zero runtime behavior change for any code that doesn't accidentally feed the proxy through Promise resolution; eliminates the latent footgun for code that does.