ionic-team / ionic-team/capacitor
[Bug]: plugin hooks not running in Nx app
- Dominant language
- TypeScript
- Stars
- 16.7k
- Forks
- 1.3k
- Avg merge
- 3d 15h
- Merged PRs (30d)
- 10
Description
### Capacitor Version
💊 Capacitor Doctor 💊
Latest Dependencies:
@capacitor/cli: 8.4.2
@capacitor/core: 8.4.2
@capacitor/android: 8.4.2
@capacitor/ios: 8.4.2
Installed Dependencies:
@capacitor/android: 8.4.2
@capacitor/cli: 8.4.2
@capacitor/core: 8.4.2
@capacitor/ios: 8.4.2
### Other API Details
```Shell
pnpm 11.x
node v24.x
```
### Platforms Affected
- [x] iOS
- [x] Android
- [ ] Web
### Current Behavior
Follow-up to #7606. That issue was about `capacitor:sync:before`/`capacitor:sync:after` hooks running once per plugin (from inside `node_modules`) instead of once at the repo root when an `nx.json` is present. It looks like this was since addressed by having `runHooks` invoke `runPlatformHook` once for the app root **and** once per resolved plugin (`dist/common.js`):
```js
async function runHooks(config, platformName, dir, hook) {
await runPlatformHook(config, platformName, dir, hook);
const allPlugins = await getPlugins(config, platformName);
for (const p of allPlugins) {
await runPlatformHook(config, platformName, p.rootPath, hook);
}
}
```
However, `runPlatformHook` itself still has the Nx-monorepo special-case from the same era, and it now breaks per-plugin hooks entirely:
```js
async function runPlatformHook(config, platformName, platformDir, hook) {
let pkg;
if (isNXMonorepo(platformDir)) {
pkg = await readJSON(join(findNXMonorepoRoot(platformDir), 'package.json'));
} else {
pkg = await readJSON(join(platformDir, 'package.json'));
}
const cmd = pkg.scripts?.[hook];
if (!cmd) {
return;
}
...
}
```
`isNXMonorepo`/`findNXMonorepoRoot` (`dist/util/monorepotools.js`) walk up from `platformDir` looking for the **nearest `nx.json`**. When `platformDir` is a plugin's install path deep in `node_modules` (e.g. `node_modules/.pnpm/@capgo+capacitor-social-login@8.3.38.../node_modules/@capgo/capacitor-social-login`), that walk-up still terminates at the workspace's `nx.json`, so the function reads the **workspace root** `package.json` for the hook name — never the plugin's own `package.json` — regardless of which `platformDir` was passed in for the per-plugin loop above.
Net effect: in any Nx workspace, a plugin that ships its own `capacitor:sync:before`/`capacitor:sync:after` hook in its own `package.json` has that hook **silently ignored** (`if (!cmd) return;`, no warning), because the CLI looks it up in the wrong file.
**It's not just plugins — the app's own hook is redirected too, and that's the part that doesn't make sense.** `runHooks` is called from `tasks/sync.js` as:
```js
await runHooks(config, platformName, config.app.rootDir, 'capacitor:sync:before');
```
`config.app.rootDir` is already correctly scoped to the *Capacitor app* being synced (e.g. `apps/mobile` in an Nx layout, not the workspace root) — this is exactly the directory `cap sync` is being run against, and it's the same directory that already holds all the app's other Capacitor-related npm scripts (`sync:android`, `build:android`, `configure:android`, etc.). The non-monorepo branch of `runPlatformHook` would use it correctly (`readJSON(join(platformDir, 'package.json'))`).
But because `isNXMonorepo(platformDir)` only checks "is there an `nx.json` *somewhere above* this directory", it fires for `config.app.rootDir` too (an Nx workspace's app package is, by definition, below the root `nx.json`) and redirects the lookup up to the workspace root `package.json` — a file that in a real Nx monorepo is shared across dozens of unrelated apps and libraries, and has no natural connection to any single app's Capacitor hooks. There's no fallback semantics here (e.g. "use the root only if the app itself has no matching script") — it's an unconditional redirect away from a directory that was already correct.
Concretely, in our repo (`apps/mobile` is the Capacitor app, `nx.json`/`package.json` live at the monorepo root several directories above): the CLI currently resolves `capacitor:sync:before` against the top-level workspace `package.json` — the one shared by every other Nx project in the repo — instead of `apps/mobile/package.json`, which is where such a hook would actually belong and where all our other Capacitor scripts already live. We would never want to add a Capacitor sync hook to the workspace root; it would be meaningless there (which app would it even apply to?).
### Expected Behavior
`runPlatformHook` should read `platformDir/package.json` directly — whether `platformDir` is the app's own root (`config.app.rootDir`) or a plugin's own root (`p.rootPath`) — regardless of whether the workspace happens to be an Nx monorepo. The Nx-monorepo special case doesn't appear to serve a real purpose here: `platformDir` is always already the correct, specific directory to look in (that's the whole point of passing it in per-app / per-plugin), so redirecting to a distant shared root can only ever pick the wrong file. If there's a specific historical reason for the Nx-root redirect, it should at minimum be a *fallback* (only consulted when `platformDir` itself has no matching script), not the default/only lookup path.
### Project Reproduction
Minimal repro shape (based on `gabides/capacitor-sample-app-sync-issue` from #7606, updated for the new per-plugin behavior):
1. Create an Nx workspace (`nx.json` at the root) with pnpm/npm workspaces, with the Capacitor app living in a subdirectory (e.g. `apps/mobile`), as is the standard Nx layout.
2. Add a `capacitor:sync:before` script to the **app's own** `package.json` (`apps/mobile/package.json`) — this is the natural, expected place for it, right alongside the app's other Capacitor scripts (`sync:android`, `build:android`, etc.).
3. Also link in a Capacitor plugin that defines its own `capacitor:sync:before` script in **its own** `package.json`.
4. Leave the **workspace root** `package.json` (`/package.json`) with no `capacitor:sync:before` script — there's no reason it should have one; it isn't specific to any single app.
5. Run `npx cap sync` from `apps/mobile`.
6. Observe: neither the app's own hook nor the plugin's hook runs, because both lookups get redirected to the workspace root `package.json`, which has nothing under that key.
Real-world instance we hit: `@capgo/capacitor-social-login` ships `"capacitor:sync:before": "node scripts/configure-dependencies.js"` in its own `package.json` to conditionally enable/disable native provider dependencies (Google/Facebook/Apple SDKs) in its podspec/`gradle.properties` based on `capacitor.config.ts`. In our Nx + pnpm workspace (app at `apps/mobile`, workspace root several directories up) this hook never fires during `cap sync`, so the plugin always ships all provider SDKs regardless of config. And if we wanted to add our *own* app-level `capacitor:sync:before`/`capacitor:sync:after` hook, we'd have to put it in `apps/mobile/package.json` to match the rest of the app's scripts — but the CLI would ignore it there and look at the monorepo root instead.
### Project Reproduction
https://github.com/LouisTrezzini/capacitor-cli-nx-repro
### Additional Information
Workaround we're using until this is fixed: after `cap sync`, we manually reconstruct the same `CAPACITOR_CONFIG`/`CAPACITOR_PLATFORM_NAME` env vars Capacitor would have passed (via `cap config --json`, extracting `.app.extConfig`) and invoke the plugin's hook script directly, with `cwd` set to the plugin's install directory rather than relying on `cap sync` to do it.
Contributor guide
Research direction
Start with runPlatformHook in dist/common.js and review the Nx path helpers in dist/util/monorepotools.js, then inspect its call from tasks/sync.js. Use the linked Nx reproduction to verify hooks declared in the app and plugin package.json files run during cap sync, while an unrelated workspace-root package.json is not used.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- node.js, typescript
- Domain
- cli
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 76/100