Plugin proxy is unintentionally Thenable: get-trap default returns function wrapper for 'then' / 'catch' / 'finally'
Nobody has claimed this yet.
Assessment
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Newbie friendliness
- 68/100
- Issue type
- Bug
- Clarity
- Clearly specified
- Activity status
- Quiet
- Tech stack
- javascript, typescript
- Domain
- api, mobile-dev
Research direction
Start at core/src/runtime.ts:162-181 and inspect the registerPlugin() proxy get trap, then trace the Promise-resolution failure described in the issue. Done means the proxy does not expose callable then, catch, or finally properties to Promise machinery while existing plugin method wrapping remains unchanged.
Written by the indexing model from the issue text.
Description
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
.thenon 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
@PluginMethodforthen→ 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 towindow.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 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):
// 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.
- Dominant language
- TypeScript
- Stars
- 16.7k
- Forks
- 1.3k
- Avg merge
- 3d 10h
- Merged PRs (30d)
- 10
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from ionic-team/capacitor
-
triage
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
ionic-team/capacitor#8616 ·
-
triage
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
ionic-team/capacitor#8601 ·
-
triage
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
ionic-team/capacitor#8574 ·
-
SystemBars: safe area CSS injection throws "Cannot read properties of null" on startup (Android 16) Opentriage
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
ionic-team/capacitor#8530 · 2 comments ·
-
triage
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
ionic-team/capacitor#8529 ·
All issues in ionic-team/capacitor
Similar issues
-
calcite-components needs triage refactor
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
Esri/calcite-design-system#15203 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 91/100
-
community first-timers-only good first issue hacktoberfest help wanted low hanging fruit up-for-grabs
Difficulty 1/5 Under an hour Newbie friendliness 95/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
Automattic/studio#4908 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 90/100