# Gotcha registry — alpha sandbox + flow-surfaces contract

The traps that eat the most trial-and-error when reproducing from a prototype. Pre-load these; they stop the failure mode where an agent grinds on a contract error, gives up, and collapses the page into one big JS block. Verified against NocoBase 2.1.0-alpha.45 on a live instance. Deeper RunJS reference: `nocobase-ui-builder` (`runjs-*`, `js.md`).

## JS sandbox

- **`ctx.formValues` does not exist.** Read form values with `ctx.form.getFieldsValue()` / `getFieldValue(name)`. For live re-render on input, subscribe `ctx.blockModel.on('formValuesChange', render)` and **dedupe** the handler (store it on `ctx.blockModel.__h`, `off` before `on`) or renders stack.
- **JSFieldModel libs live under `ctx.libs.*`** — `ctx.libs.React` / `ctx.libs.antd` / `ctx.libs.antdIcons`. `ctx.antdIcons` is **rejected** by the RunJS validator in a field renderer. (A standalone `JSBlockModel` may use `ctx.antd` directly, but field/column renderers go through `ctx.libs`.) Read the whole record with `await ctx.getVar('ctx.record')`.
- **No local variable named `location`** — `const location = ...` trips `RUNJS_FORBIDDEN_GLOBAL` (treated as the bare `location` global). Rename it (`placeStr`). Reading `record.from_location` (a property) is fine.
- **No `window` / `document` / `localStorage` / `sessionStorage` / `URLSearchParams`.** Router/navigation via `ctx.router`; real document only via `ctx.element.ownerDocument` if unavoidable.
- **Font strings can't contain spaces** in the SES sandbox — `"SF Mono"` → SyntaxError. Use `SFMono-Regular`, `monospace`.
- **Every enum→Tag/color map must fall back to the raw value** for unknown values, or rendering throws "Tag not found". `const c = MAP[v] || 'default'; const label = LABEL[v] || v || '-'`.
- **No emoji in `ctx.render` / card HTML** — emoji and HTML entities (`📍`, `&#128205;`) render as `□` tofu boxes in the sandbox. Use `ctx.libs.antdIcons`, a text abbreviation, or an SVG instead.
- **GridCard CAN host a JS card body** — the previous claim that it can't was wrong (verified alpha.45). `add-field --renderer js` on a `GridCardItemModel` creates `DetailsItemModel` → `JSFieldModel` and the JS **does execute**. The issue was the native sibling `DetailsItemModel` nodes (name/asset_no/category) staying in place above the JS card. Fix: `remove-node` those siblings, then `configure --changes {showLabel:false}` on the remaining DetailsItemModel wrapper. After cleanup, `GridCardBlockModel` gives native multi-column grid + rich JS-rendered cards. Full build recipe in `block-recipes.md §3`.
- **Don't put a top-level `ctx.resource.refresh()` in a re-rendering container** (filter-form child, a block that fires `formValuesChange`, a toolbar action that remounts on refresh) — it re-fires every render → "won't stop searching". Cache the result on `ctx.model` (`if (ctx.model.__n === undefined) { …fetch…; ctx.model.__n = n }`).
- **Don't re-apply a filter in a mount `useEffect`** — toolbar actions remount with the parent block refresh, so a `useRef` guard resets and you get apply→refresh→remount→apply forever. Store one-shot flags on `ctx.model` (survives remount), not `useRef`.
- **Live feed / countdown**: put `setInterval` inside a per-row React component that only `setState`s locally; a top-level interval re-fetches the whole resource every tick.
- **List/Details inline JSField fetching association data**: use a real React component with `useState`/`useEffect` + `ctx.api.request` + `setState`; `ctx.model.rerender()` is unreliable on a JSField. `resourceSettings.init.appends` on a List is ignored — fetch per row and cache on `ctx.blockModel.__cache`.

- **SES transpiler chokes on two innocuous JS patterns** (stable `SyntaxError: Unexpected token '.'` at save/run): bare leading-dot numerics (`opacity:.85` → write `0.85`) and deeply nested multi-line object literals inside `createElement` args (keep each `style:{…}` on a single line; extract nested elements into named consts).

## Native popup from JS

- **`ctx.openView('<table ViewActionModel uid>', { mode:'drawer', size:'medium', dataSourceKey:'main', collectionName:'<coll>', filterByTk: record.id, params:{ filterByTk: record.id } })`** is the reliable way to open a native drawer/dialog from a JS cell/block. The uid must be a **real table's ViewActionModel** (`hasCurrentRecord:true`).
- A **hand-built standalone PopupActionModel** triggered from JS fails with `reading 'dataSource'` and an empty drawer — JS-triggered plain popups have `hasCurrentRecord:false` so native data blocks can't bind. Mint a View action on the table (`add-record-action --use ViewActionModel`) and point `openView` at it.
- RunJS skill-mode flags `ctx.openView` as `RUNJS_BLOCKED_CTX_CAPABILITY` (reference-only) — ignore for this case; it runs correctly (the page's own record actions use it).

## flow-surfaces / grid contract

- **Edit deployed code with `curl flowModels:save`** (flat full-field upsert; preserves `parentId`). **Never `flowModels:update`** — it clears `parentId` and the node disappears.
- **After any add/remove block, sync all three grid representations**: v2 `props.layout` (the render source), legacy `props.rows`/`rowOrder`, and `stepParams.gridSettings.grid`. A stale `props.layout` pointing at a removed block makes the client throw a false **"Collection may have been deleted. Please remove this Block"** (the collection is fine). `set-layout` validates full coverage but on some grids leaves `props.layout` null (null = fall back to legacy rows, usually fine) — **if side-by-side columns render stacked vertically after `set-layout`, write the v2 `props.layout` array explicitly via `flowModels:save`**. After a bad write the client **caches the broken schema — hard-refresh, F5 isn't enough.**
- **`create-page --menu-route-id` fails against a pre-created flowPage route** — the working path is compatibility-mode `create-page` (auto-creates menu+page+tab+grid), then re-parent the route under the menu group. Also m2o `reverseField` 500s unless `target`/`foreignKey`/`sourceKey` are explicit.
- **List `add-block` rejects `currentRecord` binding** on a page grid — use `resourceInit`. The backend forces ≥3 visible business fields on create; create with 3 real fields, then `remove-node` and attach the JS renderer.
- **⚠️ `remove-node` can FALSE-SUCCEED**: it returns "removed" yet the node is still there (seen with List-item native fields). **Always verify after removal** — `flow-surfaces get --page-schema-uid <uid>` and check the uid is gone from `nodeMap`; if it persists, retry or remove via a different path (e.g. parent `configure`/`flowModels:save`). Symptom when missed: leftover native field strips stacked above/below your JS card.
- **Kanban card field limit is 2** — remove plain fields and add one JS card field for a rich card.
- **Kanban per-column list requests ignore `appends`** — m2o relations are absent from the card record even with `resourceSettings.init.appends` set. Fetch the full record (with appends) inside the JS card per row and cache on `ctx.blockModel.__cache` (same pattern as the List per-row fetch gotcha).
- **m2o FK is camelCase, seed silently drops snake_case**: `fields apply` on an m2o creates the FK column as `supplierId`/`itemId` (camelCase), not `supplier_id`. Seeding rows with `supplier_id` is silently dropped (the relation ends up empty). Seed with the camelCase FK, or pass the nested relation object, then verify the relation populated.
- **`add-block` flags vs `--body` are mutually exclusive**: you can't mix individual `--target`/`--use` flags with `--body`. Any block needing `defaults`/`fieldGroups` must go fully through `--body`.
- **GridCard column count**: the working knob is `columns: 3` (plain number, NOT a responsive object, NOT `columnCount` — that one is rejected by configure). A responsive `columns` object renders single-column (`ant-col-24`).
- **`add-field --renderer js`** → whole-record JS field/column (reads `ctx.record`); **`add-field --type jsItem`** → custom form item (JSItemModel). Drop a leftover field label with `configure --changes {showLabel:false}` (not `label.hidden`, which throws React #31).
- **Gantt is invisible to flow-surfaces** (alpha.45): `plugin-gantt` registers only in the legacy schemaInitializer, so `catalog` doesn't list it and `add-block --use GanttBlockModel` 400s — yet the client class IS registered. Hand-build the node via `flowModels:save`: `resourceSettings.init {dataSourceKey, collectionName}` + `ganttSettings {title, start, end, color, timeScale}` + a TableColumnModel label column + TableActionsColumnModel. Pointing `color` at a select field auto-uses the enum option colors.
- **JS in a block's action toolbar** (top-right, next to Filter/Add new): `add-action --use JSItemActionModel` for a **rendered** widget (`ctx.render`, e.g. quick-filter pills) or `--use JSCollectionActionModel` for a click-logic button. Both verified addable to List/Table toolbars in alpha.45 (`catalog --sections '["actions"]'` lists them). So pills/custom controls don't need a separate block row — they can live in the toolbar.
- **ViewAction popup on a >10-field collection requires `popup.defaults.collections.<coll>.fieldGroups` covering ALL generated popup fields — including FK columns AND system fields (id/createdAt/updatedAt/createdById/updatedById)**. Missing system fields fails with a misleading `"... is required"` instead of "does not cover".
- **Validation/SQL**: `validation:{required,maxLength}` without a Joi `type` permanently 500s the collection. Sorting a block by a non-existent column → "Invalid SQL column or table reference" render failure — sort by a real column.
- **Chart** has no statistic/number type in alpha (transparent canvas) → big-number KPIs go in a JS block. data-viz `dataScope.filter` = `{path, operator, value}`; number fields can't go in `defaultFilter.items`.
- **⚠️ dataScope variables are SILENTLY DROPPED client-side** (verified on Calendar, alpha.45): `{{$user.id}}` / `{{$user.nickname}}` resolve fine on the server REST path, but the block's client dataScope parser fails them and **drops the whole filter item** — the list request goes out unfiltered. Workaround: use a literal value; leave a note to switch back to the variable once fixed. Always verify by capturing the actual `:list` request, not by trusting the saved config.

## Verification

Playwright, fresh no-cache browser, inject `localStorage NOCOBASE_TOKEN`, open `/admin/<pageSchemaUid>`. Assert: every block renders; no console/pageerror; no "Collection may have been deleted" / "Tag not found" / "Invalid SQL" / `dataSource`; interactions work (filter filters, checkbox persists to DB, click opens native drawer). Then do the visual pass (`visual-loop.md`). Reads use `nb` CLI; the only write that needs raw curl is `flowModels:save`.
