Plugin proxy is unintentionally Thenable: get-trap default returns function wrapper for 'then' / 'catch' / 'finally'

Open Beginner friendly
#8,472 2 comments 1 reaction 0 assignees View on GitHub

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
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

needs reproduction

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:

  1. Consumer code: const pluginPromise = import("some-capacitor-plugin").then((module) => module.SomePlugin); — common dynamic-import pattern for lazy plugin loading
  2. The .then((module) => module.SomePlugin) callback returns the plugin proxy
  3. Promise resolution machinery checks: is this returned value a Thenable? Reads .then on the proxy
  4. Proxy get-trap returns createPluginMethodWrapper("then") — a function — JS engine treats proxy as Thenable
  5. Promise resolution invokes proxy.then(resolve, reject) to chain
  6. The wrapper dispatches cap.nativePromise(pluginName, "then", resolve) to the native bridge
  7. No native plugin has @PluginMethod for then → bridge rejects with "PluginName.then() is not implemented on android" (or platform-equivalent)
  8. 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:

  1. 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.
  2. 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.
  3. 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.

Dominant language
TypeScript
Stars
16.7k
Forks
1.3k
Avg merge
3d 10h
Merged PRs (30d)
10

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from ionic-team/capacitor

All issues in ionic-team/capacitor

Similar issues

More TypeScript issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.