6.8 KiB
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
// 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
<AppProvider>. 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:
INSERT INTO plugin_developer (id, user_id, email, created_by)
VALUES (gen_random_uuid(), '<userId>', '<email>', '<adminUserId>');
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:
- Upload bundle → get pluginId
- Enable via
PUT /base/:baseId/plugin-config/:pluginId - 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.
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:
- Check
plugin.jsonpositions— it must include the position you're installing to (e.g."positions": ["contextMenu"]) - Check
onLoadregistration —ctx.addContextMenuAction('main', Component)must be called insideonLoad - 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.
- Check browser console — errors in
onLoadare silently caught; add aconsole.logat the top ofonLoadto 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.jsonhas"jsx": "react-jsx"undercompilerOptions- The plugin does NOT need
import React from 'react'at the top when usingreact-jsxtransform — JSX is auto-transpiled. But you still needconst React = host.Reactfor any explicitReact.createElementcalls.
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/<id>/runtime/'.
Pitfall 9: Bundle must be IIFE format after a successful build
Three producer-side causes, all seen in the wild:
- 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 })]. - Development-mode webpack output starts with
/******/banner comments. The dev watch loop therefore builds with--mode production. - Vite/esbuild IIFE output starts
var <name>=(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.