brain/wiki/engineering/web-feature-anatomy.md
What a frontend feature looks like in packages/web/src/. The canonical reference is features/tables/ — when this page and that folder disagree, the folder wins.
features/{feature}/
api/ # api clients — tables-api.ts, fields-api.ts
components/ # React components
hooks/ # react-query hooks — table-hooks.ts
stores/ # zustand stores, when the feature has client state
types/
utils/
index.ts # barrel — the feature's public surface
Everything crossing the feature boundary goes through index.ts. See features/tables/index.ts: React components are exported by name (ApTableHeader, ImportTableDialog), while plain function/constant utils are grouped into one object first (tablesApi, tableHooks) and re-exported as that object.
API client: features/tables/api/tables-api.ts. Hooks: features/tables/hooks/table-hooks.ts.
On any query that fetches a page's primary data — the table rows, the list, the thing the page exists to show — set meta: { showErrorDialog: true }. QueryCache.onError in app/query-client.ts turns that into the global error dialog. Leave it off for auxiliary queries (feature flags, piece metadata, single-item fetches, filter options, user details) — those should fail silently rather than throw a modal over the page.
Routes are registered in app/routes/project-routes.tsx, composed from ProjectRouterWrapper plus guards:
...ProjectRouterWrapper({
path: routesThatRequireProjectId.myFeature,
element: (
<RoutePermissionGuard requiredPermissions={Permission.READ_MY_FEATURE}>
<PageTitle title="My Feature">
<SuspenseWrapper>
<MyFeaturePage />
</SuspenseWrapper>
</PageTitle>
</RoutePermissionGuard>
),
}),
The page component itself is React.lazy()-imported. requiredPermissions takes a single Permission or an array. Guards live in app/guards/ — permission-guard.tsx, flag-route-guard.tsx, project-route-wrapper.tsx.
flagsHooks.useFlag(), or <FlagGuard> / flag-route-guard.tsx for whole routes.LockedFeatureGuard on the frontend, enabled: platform.plan.<flag> on the query. The backend counterpart is platformMustHaveFeatureEnabled(), which returns 402.packages/web/public/locales/en/translation.json only — the other locales are generated. Zod validation messages must be keys in that file, not raw English; reuse the formErrors constant from @activepieces/shared for common ones.Every customer-facing surface must be checked on all five edition paths — CE, EE self-hosted, Cloud freemium, Cloud self-serve paid, Cloud enterprise. Nothing user-visible hardcodes "Activepieces": name, colours, and logos come from platform appearance. Community always gets the default theme, Cloud always applies platform branding, EE requires platform.plan.customAppearanceEnabled. See ee/helper/appearance-helper.ts.
Verify with npx turbo run lint --filter=web, or npm run lint-dev for the whole repo.
showErrorDialog on the wrong query is worse than missing it. On an auxiliary query it throws a modal over a page that was working fine; on the primary query, omitting it leaves the user staring at an empty table with no explanation.const ref = useRef(x); ref.current = x) is stale inside socket/event callbacks. The value only advances when React commits a render, so two events handled before that commit both read the same base — a read-modify-write (merging a step into run.steps) silently drops the earlier event. Read the zustand store directly instead: useBuilderStore().getState() (app/builder/builder-hooks.ts) always returns current state. Bit the test-flow widget's progress merge, PR #14453.z- wins over everything — including portalled popovers. Nothing between an overlay in the canvas panel and <body> creates a stacking context (the middle panel is relative + z-auto; ResizablePanel sets only flex/overflow), so a canvas child's z-index competes directly with Radix portals. The working ladder: canvas z-30 (opaque bg-builder-background — anything below it is invisible), header and floating corner chrome z-40, data selector / canvas controls / popovers z-50. That is why the powered-by note at z-10000 painted over the piece selector..react-flow__viewport. flowScreenshotUtils (flow-canvas/utils/flow-screenshot-utils.ts) clones that one element into an SVG, so anything outside it — the dot-grid background, the powered-by note, canvas controls — is absent unless handled explicitly. Two seams: mark in-viewport chrome you want omitted (step chevron, badges) with data-flow-screenshot-exclude; anything outside the viewport you want included has to be redrawn onto the composited 2D canvas in composeImageWithCanvasBackground (that's how the background dots and the powered-by mark get there).useAdjustPieceListHeightToAvailableSpace (features/pieces/utils/piece-selector-utils.ts) measures the room above vs. below the trigger, renders the list on whichever side has more, and clamps the height to [MIN 100, MAX 300]. That measurement alone still let the popover butt flush against the top/bottom of the builder on short screens (the Radix content + its own padding/offset overran the raw available space). The fix is a PIECE_SELECTOR_CLIPPING_THRESHOLD (20px) subtracted from the computed listHeight at the call site in builder/pieces-selector/index.tsx, leaving a margin so the popover never touches the viewport edge. If it clips again, that constant — not the min/max clamp — is the lever.npx turbo run serve --filter=web -- --mode=cloud cannot do OAuth2 connections. The provider redirects to cloud.activepieces.com after sign-in instead of your local frontend. Use API-key or basic-auth connections, or run a fully local backend.