# Common pitfalls and how to avoid them These are real issues discovered during plugin development. Read before writing plugin logic — each one will save you debugging time. ## Pitfall 1: Importing `@teable/*` packages instead of using host globals ```typescript // WRONG — webpack externals map these away at build time, so at runtime // the import produces `undefined` or throws: import { useFields } from '@teable/sdk'; // → undefined at runtime import { Button } from '@teable/ui-lib'; // → undefined at runtime import { definePlugin } from '@teable/plugin-sdk'; // fine (it re-exports from host) // CORRECT — always read from the host global: const host = window.__TEABLE_HOST__; const { useFields } = host.SDK; const { Button } = host.UILib; const { definePlugin } = host.PluginSDK; const React = host.React; ``` The webpack `externals` config in the scaffold template maps `@teable/sdk` → `window.__TEABLE_HOST__.SDK` at build time. This means the bundled code replaces the import with `window.__TEABLE_HOST__.SDK`, but destructuring from the import statement doesn't survive the webpack transformation — use direct `host.*` access instead. The only exception is `@teable/plugin-sdk` which re-exports from `window.__TEABLE_HOST__.PluginSDK` at the level webpack expects. **Symptoms**: `useFields is not a function`, `Cannot read properties of undefined`, or components silently rendering nothing. ## Pitfall 2: `window.__TEABLE_HOST__` is undefined The host object is injected by `HostInitializer` which mounts inside ``. This happens after the initial React tree render. If you try to access `window.__TEABLE_HOST__` in a script that executes before the host tree mounts, it will be `undefined`. **Fix**: The `PluginLoader` component ensures the host is ready before loading plugin scripts, so plugins loaded through the normal flow don't hit this. But if you're testing a plugin directly (e.g. loading main.js in a browser console), you'll see `__TEABLE_HOST__` is null until the app renders. ## Pitfall 3: Upload returns 403 — not a developer Only users in the `plugin_developer` DB table can upload bundles. The `DeveloperGuard` checks this. If you get 403 on `upload-bundle`, your user is not whitelisted. **Fix**: Add the user to `plugin_developer`: ```sql INSERT INTO plugin_developer (id, user_id, email, created_by) VALUES (gen_random_uuid(), '', '', ''); ``` ## Pitfall 4: Enable (plugin-config) must be called before install Installing a plugin to a position (contextMenu / panel / view / dashboard) requires the plugin to be enabled first. If you skip enable and call install directly, you'll get 403. **The correct sequence is always**: 1. Upload bundle → get pluginId 2. Enable via `PUT /base/:baseId/plugin-config/:pluginId` 3. Install to position ## Pitfall 5: `getSelectionRecords` returns empty array, not throw The old iframe/ZIP `bridge.getSelectionRecords()` threw when nothing was selected. The dynamic system's `PluginSDK.getSelectionRecords()` returns an empty array instead. You still need to handle the empty case in your UI, but you don't need a try/catch wrapper. ```typescript const { getSelectionRecords } = host.PluginSDK; const res = await getSelectionRecords(tableId, viewId, selection); if (res.records.length === 0) { // Show "no records selected" message — no try/catch needed } ``` ## Pitfall 6: Plugin component renders nothing / context menu empty If your plugin uploads, enables, and installs successfully but the component never appears: 1. **Check `plugin.json` `positions`** — it must include the position you're installing to (e.g. `"positions": ["contextMenu"]`) 2. **Check `onLoad` registration** — `ctx.addContextMenuAction('main', Component)` must be called inside `onLoad` 3. **Check Authority Matrix** — the user's role must have the plugin enabled in AM. For the test account, the owner bypasses AM, but for other users, an admin must enable the plugin per role in the RoleEditor UI. 4. **Check browser console** — errors in `onLoad` are silently caught; add a `console.log` at the top of `onLoad` to verify it runs. ## Pitfall 7: Component uses JSX but webpack doesn't transpile it The scaffold template uses `ts-loader` with `jsx: "react-jsx"` in tsconfig. If you're setting up the webpack config manually, make sure: - `tsconfig.json` has `"jsx": "react-jsx"` under `compilerOptions` - The plugin does NOT need `import React from 'react'` at the top when using `react-jsx` transform — JSX is auto-transpiled. But you still need `const React = host.React` for any explicit `React.createElement` calls. ## Pitfall 8: Chunk 404 at runtime — multipart strips directory names (2026-10) `webpack output.chunkFilename: 'static/js/[name].chunk.js'` builds fine, the upload succeeds, but the browser 404s the chunk: multer/busboy flattens multipart filenames, so the server stored `469.chunk.js` while the bundle requests `static/js/469.chunk.js`. **Fix**: keep chunk names FLAT — `chunkFilename: '[name]-[contenthash].chunk.js'` (contenthash prevents collisions). Public path still works: `output.publicPath: '/api/plugin//runtime/'`. ## Pitfall 9: `Bundle must be IIFE format` after a successful build Three producer-side causes, all seen in the wild: 1. **Terser license banner** — bundling any npm package with license comments makes webpack emit `/*! For license information... */` BEFORE `!function`. Fix: `optimization.minimizer: [new TerserPlugin({ extractComments: false })]`. 2. **Development-mode webpack** output starts with `/******/` banner comments. The dev watch loop therefore builds with `--mode production`. 3. **Vite/esbuild IIFE** output starts `var =(function...` — the validator accepts it (since 2026-10); no post-processing needed. ## Pitfall 10: vite scaffold `pnpm install` fails with ERR_PNPM_IGNORED_BUILDS pnpm ≥ 10/11 blocks esbuild's postinstall and (on patched hosts) treats it as fatal. Fix (scaffold generates it): `pnpm-workspace.yaml` with `allowBuilds:\n esbuild: true`. esbuild also works without its script (binary comes from optionalDependencies) — only the pnpm gate needs the allow entry. ## Pitfall 11: PAT gets 403 fetching `/api/plugin/:id/runtime/*` Access tokens only pass routes with `@TokenAccess()` or `@Permissions` — the runtime asset serving has neither. This is by design (the browser loads with a session). For curl verification use a signed `auth_session` cookie; for uploads use the PAT (`upload-bundle` and `POST /api/plugin` are token-accessible). ## Pitfall 12: "react/jsx-runtime would duplicate React" worry It does not. The jsx automatic transform imports `react/jsx-runtime`, which is bundled into the plugin on purpose: it only creates plain element objects. Hooks/context/state go through the externalized `react` → host singleton. This is exactly how the deployed ticket-console/batch-edit-pro bundles behave.