softr-vibe-coding 2.8.2 → 2.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/README.md +16 -5
- package/SKILL.md +31 -1
- package/datasources/fields.md +1 -1
- package/datasources/multi-datasource.md +91 -0
- package/datasources/reading.md +107 -7
- package/datasources/writing.md +23 -0
- package/package.json +1 -1
- package/references/anti-patterns.md +7 -0
- package/references/common-patterns.md +1 -1
- package/references/quick-reference.md +4 -3
- package/references/searchable-dropdown.md +188 -13
- package/references/softr-mcp.md +100 -10
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,13 @@ All notable changes to this skill are documented here. Versions follow [Semantic
|
|
|
4
4
|
|
|
5
5
|
Entries from 1.3.1 onward are generated automatically from git commit subjects between version bumps (see `.github/workflows/publish.yml`). Entries before 1.3.1 were backfilled by hand from the existing commit history.
|
|
6
6
|
|
|
7
|
+
## [2.9.1] - 2026-09-30
|
|
8
|
+
- Add right-edge placement and the schema-less workspace-tool correction (2026-09-30)
|
|
9
|
+
- Document dropdown clipping by overflow ancestors (verified live 2026-09-30)
|
|
10
|
+
|
|
11
|
+
## [2.9.0] - 2026-09-29
|
|
12
|
+
- Add runtime facts verified live 2026-09-18
|
|
13
|
+
|
|
7
14
|
## [2.8.2] - 2026-09-10
|
|
8
15
|
- Corrections on evidence: Softr never strips the trailing newline; the array-argument rejection was client-side stringification, not an empty server schema (2.8.2)
|
|
9
16
|
|
package/README.md
CHANGED
|
@@ -169,7 +169,7 @@ Create a contact form that creates records in our Airtable Contacts table
|
|
|
169
169
|
softr-vibe-coding/
|
|
170
170
|
├── SKILL.md # Main skill
|
|
171
171
|
│ # Workflow, code structure, visual baseline,
|
|
172
|
-
│ # components, settings,
|
|
172
|
+
│ # components, settings, 27 hard constraints
|
|
173
173
|
│
|
|
174
174
|
├── ui-ux-guidelines.md # Design reference
|
|
175
175
|
│ # 26 sections: hierarchy, color, typography,
|
|
@@ -191,7 +191,10 @@ softr-vibe-coding/
|
|
|
191
191
|
│ │ # Softr DB schema + record tools incl. deletes,
|
|
192
192
|
│ │ # app management/scaffolding, Workflows suite
|
|
193
193
|
│ │ # (26 tools, 418-node catalog), per-application
|
|
194
|
-
│ │ # MCP servers, auth, permissions
|
|
194
|
+
│ │ # MCP servers, auth, permissions; what the server
|
|
195
|
+
│ │ # enforces on block data endpoints, "Preview as"
|
|
196
|
+
│ │ # role testing, search-replace on 100KB+ blocks
|
|
197
|
+
│ │ # (Sep 18 2026)
|
|
195
198
|
│ ├── advanced-integrations.md # Shadow DOM CSS isolation
|
|
196
199
|
│ │ # Leaflet, Mapbox, TinyMCE, Quill, FullCalendar
|
|
197
200
|
│ ├── native-chrome-styling.md # Restyle Softr's native shell (header, footer,
|
|
@@ -229,7 +232,10 @@ softr-vibe-coding/
|
|
|
229
232
|
│ # click-outside, A-Z inside the component,
|
|
230
233
|
│ # multi-token filter, searchable BY DEFAULT
|
|
231
234
|
│ # (bare = click-only; searchable={false} only
|
|
232
|
-
│ # for a fixed enum being set — Sep 10 2026)
|
|
235
|
+
│ # for a fixed enum being set — Sep 10 2026),
|
|
236
|
+
│ # overflow-clipping ancestors: never clip a cell
|
|
237
|
+
│ # holding a Combo, clip-aware drop-up + list
|
|
238
|
+
│ # height, list-only scrolling (Sep 30 2026)
|
|
233
239
|
│
|
|
234
240
|
├── tools/ # Bundled CLI scripts (run, not read)
|
|
235
241
|
│ ├── get-airtable-base # Full Airtable base schema export (bash + jq)
|
|
@@ -239,9 +245,14 @@ softr-vibe-coding/
|
|
|
239
245
|
├── overview.md # Comparison matrix, selection guide
|
|
240
246
|
├── shared-patterns.md # Index → multi-datasource, reading, writing, fields
|
|
241
247
|
├── multi-datasource.md # Several data sources in ONE block: datasource.define(),
|
|
242
|
-
│ # the from: parameter, getting the datasource UUIDs
|
|
248
|
+
│ # the from: parameter, getting the datasource UUIDs,
|
|
249
|
+
│ # select: as a module-scope identifier, the union-of-
|
|
250
|
+
│ # selects read payload (a conditional select is not
|
|
251
|
+
│ # privacy), Actions per table (Sep 18 2026)
|
|
243
252
|
├── reading.md # useRecords, filtering, sorting, pagination,
|
|
244
|
-
│ # metrics, charts, current user
|
|
253
|
+
│ # metrics, charts, current user; no detail-page
|
|
254
|
+
│ # auto-scoping, useRecords ignores enabled:false,
|
|
255
|
+
│ # server-side linked-record filters (Sep 18 2026)
|
|
245
256
|
├── writing.md # Mutations, sequential write queues, uploads,
|
|
246
257
|
│ # linked record format, cross-table writes
|
|
247
258
|
├── fields.md # getFieldValue(), field type shapes, record
|
package/SKILL.md
CHANGED
|
@@ -69,6 +69,10 @@ You generate complete, production-ready Softr Vibe Coding blocks as TypeScript R
|
|
|
69
69
|
|
|
70
70
|
6. **Self-validate before delivering.** Before presenting the code as complete, verify. (Data-hook items apply only to data-connected blocks; static marketing blocks swap in the checklist deltas from [references/static-blocks.md](references/static-blocks.md#workflow-deltas).)
|
|
71
71
|
- Every data hook is called with an **inline options object literal** — `useRecords({ ... })` written through a variable or wrapper function fails to compile (verified live 2026-08-25). Share `q.select` mappings between hooks, never whole options objects
|
|
72
|
+
- Multi-datasource block: every `select:` / `fields:` value is a **plain module-scope identifier** — no ternary, no inline `q.select({...})` inside the hook options (the query returns `fields: {}`; Hard Constraint 24)
|
|
73
|
+
- Detail page: the record is fetched with `useRecord({ select, recordId, enabled: !!recordId })` using `useCurrentRecordId()`, and the code checks `data.id === recordId` before rendering — never `useRecords({ count: 1 })`, which returns the table's FIRST row (Hard Constraint 25)
|
|
74
|
+
- No list query relies on `enabled: false` — `useRecords` fetches anyway; conditional list queries live in a child component mounted only when needed, or carry a match-nothing `where` (Hard Constraint 26)
|
|
75
|
+
- No field is "hidden" from some viewers by a conditional / second `select` on the same connection — the browser receives the union of every read select on that connection (Hard Constraint 23)
|
|
72
76
|
- All imports use named imports (no `import React from 'react'`)
|
|
73
77
|
- `export default function Block()` is present
|
|
74
78
|
- Container + content wrappers present (`<div className="container py-0"><div className="content">`) — OR a deliberate full-bleed layout recorded in the `// BLOCK PLACEMENT:` comment (see "Block Placement & Page Spacing")
|
|
@@ -86,6 +90,7 @@ You generate complete, production-ready Softr Vibe Coding blocks as TypeScript R
|
|
|
86
90
|
- Sequential multi-row saves use `await hook.mutateAsync(...)` per row, in order, with stop-on-failure + retry state — `mutateAsync` is fully supported on the current platform (verified 2026-08-25; the old ".mutate() only" Action-parser rule is gone — see [datasources/writing.md](datasources/writing.md)). Independent writes to **different tables** may run in parallel via `Promise.all`; drag/reassign UIs should be optimistic with an Undo toast — see [writing.md → Parallel writes across tables](datasources/writing.md#parallel-writes-across-tables-the-one-sanctioned-parallelism)
|
|
87
91
|
- No hardcoded domains in links -- use relative paths (`/page?recordId=...`); same-page anchors written relative too (`/#section`)
|
|
88
92
|
- **No `<select>` and no shadcn `<Select>`** — both break inside a block's shadow DOM (native hands the list to the OS; shadcn portals outside the shadow root and arrives unstyled). Use the `Combo` pattern in [references/searchable-dropdown.md](references/searchable-dropdown.md) — **searchable by default** for every framed filter or form field whatever the option count; `bare` inline editors are click-only; `searchable={false}` only on a short fixed enum the user is setting (a status, a location, a group-by)
|
|
93
|
+
- No clipping class (`overflow-hidden`, `overflow-*-auto`, `truncate`, `line-clamp-*`) on any element that contains a `Combo` — its panel is absolutely positioned in local DOM, so a clipping `<td>` cuts the menu to the row's height; bound an over-wide chip at the chip (`min-w-0 truncate`), and never `scrollIntoView` inside the panel. See [references/searchable-dropdown.md](references/searchable-dropdown.md#the-four-things-that-will-bite-you), item 4
|
|
89
94
|
- Static block: no hardcoded user-visible copy — every string/image/link is an editable setting (see [references/editable-settings.md](references/editable-settings.md#granularity-doctrine-settings-first-static-blocks))
|
|
90
95
|
- Array-setting rows keyed by **index**, never by a builder-editable field value
|
|
91
96
|
- Media settings that may start empty (`src: ""`) gated with a conditional render or placeholder — never an unconditional `<img src={setting.src}>`
|
|
@@ -556,7 +561,7 @@ Non-negotiable rules. Most are enforced by the Softr platform (compiler, validat
|
|
|
556
561
|
restore worked. Softr's default for a `genericActions` ADD_RECORD is `ALL_USERS`, i.e. writable by
|
|
557
562
|
logged-OUT visitors, and the MCP call that re-tightens it (`set_vibe_coding_block_action_visibility`)
|
|
558
563
|
can itself fail with no fallback (see the array-argument quirk in
|
|
559
|
-
[references/softr-mcp.md](references/softr-mcp.md#the-array-argument-
|
|
564
|
+
[references/softr-mcp.md](references/softr-mcp.md#the-array-argument-rejection-and-why-it-is-a-security-issue)).
|
|
560
565
|
A push that returns `errors: null` can still have left public write access on the block.
|
|
561
566
|
**If any action is still `ALL_USERS`, report it WITH its severity and let the builder decide.**
|
|
562
567
|
Check the page's own VIEW permission first (`get_page_permissions`): a page gated to logged-in
|
|
@@ -574,6 +579,31 @@ Non-negotiable rules. Most are enforced by the Softr platform (compiler, validat
|
|
|
574
579
|
are shared, and promise nothing beyond them (the class strings usually differ in layout and padding,
|
|
575
580
|
and do not need to match). The same rule governs repeated page chrome -- see **Block Placement &
|
|
576
581
|
Page Spacing**.
|
|
582
|
+
23. **One connection = one read payload; a conditional select is not privacy** -- the records
|
|
583
|
+
endpoint is per block + connection and returns the UNION of every field named by any READ
|
|
584
|
+
`q.select` on that connection, to every viewer. A second or ternary select "only for admins"
|
|
585
|
+
hides nothing. Put a private field on a **second connection of the same table** (allowed) read
|
|
586
|
+
only by a hook non-privileged browsers never run, or in a group-gated block. Page VIEW permission
|
|
587
|
+
is enforced on these endpoints, but on a page any logged-in user may view, every connected
|
|
588
|
+
datasource is readable by any logged-in user who crafts the request -- Source conditions are the
|
|
589
|
+
only server-side ROW gate. Verified live 2026-09-18. See
|
|
590
|
+
[datasources/multi-datasource.md](datasources/multi-datasource.md#one-connection--one-read-payload-the-union-of-its-selects).
|
|
591
|
+
24. **Multi-datasource: `select:` is a plain module-scope identifier** -- a ternary
|
|
592
|
+
(`select: a ? X : Y`) cannot be attributed to a connection and the query returns `fields: {}`, no
|
|
593
|
+
error. Treat an inline `q.select({...})` inside hook options the same way: hoist it. Verified
|
|
594
|
+
live 2026-09-18.
|
|
595
|
+
25. **No detail-page auto-scoping** -- the runtime sends `pageContext: null`;
|
|
596
|
+
`useRecords({ count: 1 })` returns the table's FIRST row, not the URL's record. Fetch detail
|
|
597
|
+
records with `useRecord({ select, recordId, enabled: !!recordId })` (`useCurrentRecordId()` does
|
|
598
|
+
return the URL's `recordId`) and verify `data.id === recordId` -- a null id falls back to a list
|
|
599
|
+
call. Verified live 2026-09-18. See [datasources/reading.md](datasources/reading.md#userecord----fetch-a-single-record).
|
|
600
|
+
26. **`useRecords` ignores `enabled: false`** -- literal or variable, it fetches anyway. `useRecord`
|
|
601
|
+
honours it. Make a list query conditional by mounting it in a child component only when needed,
|
|
602
|
+
or with a match-nothing `where`. Verified live 2026-09-18.
|
|
603
|
+
27. **Mutation Actions register per TABLE, not per connection** -- several `useRecordUpdate` hooks on
|
|
604
|
+
one table merge into ONE UPDATE_RECORD action (field list = the union), filed under the table's
|
|
605
|
+
FIRST connection even when a hook points at a second one. Point writes at the first connection.
|
|
606
|
+
Verified live 2026-09-18. See [datasources/writing.md](datasources/writing.md#actions-register-per-table-not-per-hook-or-connection).
|
|
577
607
|
|
|
578
608
|
## Style Conventions
|
|
579
609
|
|
package/datasources/fields.md
CHANGED
|
@@ -60,7 +60,7 @@ You'll see exactly which field is an object. Add `getFieldValue()` around it.
|
|
|
60
60
|
| Date Range | `{ from: string, to: string }` |
|
|
61
61
|
| Rating, Duration | `string or number or null` |
|
|
62
62
|
| Select | `{ label: string, id: string }` |
|
|
63
|
-
| Linked Record (via useRecord/useRecords) | `{ label: string, id: string }` |
|
|
63
|
+
| Linked Record (via useRecord/useRecords) | `{ label: string, id: string }` — usually an array of these, but a link can arrive as a **single object** (verified live 2026-09-18); normalise with `Array.isArray(v) ? v : (v ? [v] : [])` |
|
|
64
64
|
| Linked Record (via useLinkedRecords) | `{ id: string, title: string }` -- different! |
|
|
65
65
|
| User, Created By, Updated By | `{ avatarUrl, id, name, email }` |
|
|
66
66
|
| Attachment | `{ filename, id, type, url }` |
|
|
@@ -64,6 +64,97 @@ var ds = datasource.define({ people: "74d2cbfd-…" });
|
|
|
64
64
|
The error text is explicit, so this one fails fast rather than silently — but it's an easy
|
|
65
65
|
reflex to hoist "magic strings" into named constants, and that reflex is wrong here.
|
|
66
66
|
|
|
67
|
+
## `select:` must be a plain module-scope identifier
|
|
68
|
+
|
|
69
|
+
*Verified live 2026-09-18 (Softr Database; probe block + network capture in a draft preview).*
|
|
70
|
+
|
|
71
|
+
With more than one connection, Softr has to attribute every `q.select` to the connection it is
|
|
72
|
+
used with. The observed behaviour says it does that statically, from the identifier you pass as
|
|
73
|
+
`select:` (the mechanism is inferred; the outcome below is what was captured). An expression
|
|
74
|
+
breaks the attribution:
|
|
75
|
+
|
|
76
|
+
```jsx
|
|
77
|
+
// WRONG — compiles, runs, and the query returns records with `fields: {}`. No error.
|
|
78
|
+
var order = useRecord({ from: ds.orders, select: isAdmin ? adminSelect : publicSelect, recordId: id });
|
|
79
|
+
|
|
80
|
+
// CORRECT — one module-scope identifier per hook
|
|
81
|
+
var orderSelect = q.select({ title: "FIELD_ID1", status: "FIELD_ID2" });
|
|
82
|
+
var order = useRecord({ from: ds.orders, select: orderSelect, recordId: id });
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Treat an inline `q.select({...})` written inside the hook options the same way: hoist it to
|
|
86
|
+
module scope and pass the identifier (the pattern at the top of this file already does). The
|
|
87
|
+
same goes for a mutation hook's `fields:`.
|
|
88
|
+
|
|
89
|
+
In a **single-datasource** block the same ternary *works* — there is nothing to attribute — but
|
|
90
|
+
it behaves as a **union** of both branches, not a choice between them. Which is the next rule.
|
|
91
|
+
|
|
92
|
+
## One connection = one read payload (the union of its selects)
|
|
93
|
+
|
|
94
|
+
*Verified live 2026-09-18.*
|
|
95
|
+
|
|
96
|
+
The records endpoint is per block + connection —
|
|
97
|
+
`/blocks/<blockId>/datasources/<dataSourceId>/records` — and it returns the **UNION of every field
|
|
98
|
+
named by any READ `q.select` attributed to that connection**. Two selects on one connection do
|
|
99
|
+
NOT produce two payloads: every read hook on that connection gets all the fields, for every
|
|
100
|
+
viewer.
|
|
101
|
+
|
|
102
|
+
**So "request the private field only for admins" is not privacy.** A second select, or a ternary
|
|
103
|
+
between a public and an admin select, still ships the private field to every browser that loads
|
|
104
|
+
the block — it is simply not rendered. Anyone can read it in the network tab.
|
|
105
|
+
|
|
106
|
+
What does *not* join the union: a mutation hook's `fields:` select. Write-only fields stay out of
|
|
107
|
+
the read payload.
|
|
108
|
+
|
|
109
|
+
**Remedy.** Connect the **same table a second time** — Softr allows it, and the second connection
|
|
110
|
+
gets its own `dataSourceId` — and read the private field only through that connection, from a
|
|
111
|
+
hook that non-privileged browsers never run:
|
|
112
|
+
|
|
113
|
+
```jsx
|
|
114
|
+
var ds = datasource.define({
|
|
115
|
+
orders: "11111111-…", // everyone: public fields only
|
|
116
|
+
ordersAdmin: "22222222-…", // same table, second connection: the private fields
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
var orderSelect = q.select({ title: "FIELD_ID1", status: "FIELD_ID2" });
|
|
120
|
+
var orderAdminSelect = q.select({ internalNotes: "FIELD_ID9" });
|
|
121
|
+
|
|
122
|
+
// Mounted by Block() ONLY when the viewer is an admin — so a non-admin browser never
|
|
123
|
+
// issues the request. (`useRecord` honours `enabled: false`; `useRecords` does NOT —
|
|
124
|
+
// see reading.md — which is why the gate is the mount, not an option.)
|
|
125
|
+
function AdminNotes({ recordId }) {
|
|
126
|
+
var admin = useRecord({ from: ds.ordersAdmin, select: orderAdminSelect, recordId: recordId, enabled: !!recordId });
|
|
127
|
+
// …
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Or put the private field in a separate block whose visibility is group-gated.
|
|
132
|
+
|
|
133
|
+
Know what this buys you. Not *rendering* the hook keeps the field out of ordinary browsers, but
|
|
134
|
+
the endpoint still exists: page VIEW permission is enforced on it (a viewer who cannot view the
|
|
135
|
+
page gets a 403), yet on a page any logged-in user may view, **every connected datasource is
|
|
136
|
+
readable by any logged-in user who crafts the request**. A connection's **Source conditions are
|
|
137
|
+
the only server-side ROW gate**; the only server-side gate on the *field* is a page or block the
|
|
138
|
+
viewer cannot see. See [../references/softr-mcp.md](../references/softr-mcp.md#what-the-server-enforces-on-a-blocks-data-endpoints).
|
|
139
|
+
|
|
140
|
+
Alias → field attribution is per connection, so two selects on different connections may reuse
|
|
141
|
+
an alias name (`customer` on both) without colliding — including in `where` filters.
|
|
142
|
+
|
|
143
|
+
## Mutation Actions register per TABLE, not per connection
|
|
144
|
+
|
|
145
|
+
*Verified live 2026-09-18.*
|
|
146
|
+
|
|
147
|
+
The second connection above is for **reads**. Actions are filed per table:
|
|
148
|
+
|
|
149
|
+
- Several `useRecordUpdate` hooks on one table merge into **ONE `UPDATE_RECORD` action** whose
|
|
150
|
+
field list is the union of their `fields:` selects.
|
|
151
|
+
- A hook pointed at the *second* connection of a table (`from: ds.ordersAdmin`) was still filed
|
|
152
|
+
under the **first** connection's `dataSourceId`.
|
|
153
|
+
|
|
154
|
+
So **point every write at the table's first connection**, and expect one action per table and
|
|
155
|
+
operation in `get_vibe_coding_block_settings` / the Actions tab — that is the row you re-tighten
|
|
156
|
+
after each push. Details in [writing.md](writing.md#actions-register-per-table-not-per-hook-or-connection).
|
|
157
|
+
|
|
67
158
|
## Getting the datasource ids — ask for CODE, never for a value
|
|
68
159
|
|
|
69
160
|
The id is a plain **UUID**. It is *not* the underlying table id (`tbl…` in Airtable), and not
|
package/datasources/reading.md
CHANGED
|
@@ -5,11 +5,11 @@ Fetching, filtering, sorting, pagination, metrics, charts, and current user.
|
|
|
5
5
|
## Table of Contents
|
|
6
6
|
|
|
7
7
|
- [Query Builder](#query-builder)
|
|
8
|
-
- [useRecords -- Fetch a Paginated List](#userecords----fetch-a-paginated-list)
|
|
9
|
-
- [useRecord -- Fetch a Single Record](#userecord----fetch-a-single-record)
|
|
8
|
+
- [useRecords -- Fetch a Paginated List](#userecords----fetch-a-paginated-list) — incl. [`enabled: false` is ignored](#userecords-ignores-enabled-false)
|
|
9
|
+
- [useRecord -- Fetch a Single Record](#userecord----fetch-a-single-record) — the detail-page pattern; no auto-scoping
|
|
10
10
|
- [useLinkedRecords -- Fetch Linked/Related Options](#uselinkedrecords----fetch-linkedrelated-options)
|
|
11
11
|
- [useFieldOptions -- Fetch Single/Multi-Select Choices](#usefieldoptions----fetch-singlemulti-select-choices)
|
|
12
|
-
- [Filtering](#filtering)
|
|
12
|
+
- [Filtering](#filtering) — incl. [server-side linked-record filters](#filtering-by-a-linked-record-server-side)
|
|
13
13
|
- [Sorting](#sorting)
|
|
14
14
|
- [Current User](#current-user)
|
|
15
15
|
- [Metrics](#metrics)
|
|
@@ -29,6 +29,15 @@ var select = q.select({
|
|
|
29
29
|
});
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
+
**Declare every `q.select` at module scope and pass it by identifier** (verified live 2026-09-18).
|
|
33
|
+
In a multi-datasource block a `select:` that is a ternary (`select: a ? X : Y`) cannot be
|
|
34
|
+
attributed to a connection and the query returns `fields: {}` with no error; treat an inline
|
|
35
|
+
`q.select({...})` inside hook options the same way and hoist it. And a connection's read payload
|
|
36
|
+
is the **union** of every read select on it — a second or conditional select never narrows what
|
|
37
|
+
the browser receives, so it is not a privacy tool. Both rules, with the remedy, in
|
|
38
|
+
[multi-datasource.md](multi-datasource.md#select-must-be-a-plain-module-scope-identifier).
|
|
39
|
+
(The short inline `q.select` snippets below are single-datasource illustrations.)
|
|
40
|
+
|
|
32
41
|
## useRecords -- Fetch a Paginated List
|
|
33
42
|
|
|
34
43
|
```jsx
|
|
@@ -39,7 +48,7 @@ var result = useRecords({
|
|
|
39
48
|
count: 6, // records per page (default 6, max 100)
|
|
40
49
|
where: q.text("name").contains("Alice"), // optional filter
|
|
41
50
|
orderBy: q.desc("createdAt"), // optional sort
|
|
42
|
-
enabled: true, //
|
|
51
|
+
enabled: true, // accepted, but `false` is IGNORED — see below
|
|
43
52
|
});
|
|
44
53
|
|
|
45
54
|
var data = result.data;
|
|
@@ -66,6 +75,33 @@ objects.
|
|
|
66
75
|
|
|
67
76
|
A block can connect to **several data sources** and call `useRecords` once per source — declare them with `datasource.define()` and pass `from:` on every hook. See [multi-datasource.md](multi-datasource.md). (This replaces the old one-table-per-block limit; blocks no longer need an invisible helper block just to read a second table.)
|
|
68
77
|
|
|
78
|
+
### `useRecords` ignores `enabled: false`
|
|
79
|
+
|
|
80
|
+
*Verified live 2026-09-18 (network capture).* `useRecords({ ..., enabled: false })` **fetches
|
|
81
|
+
anyway** — with a literal `false` and with a variable alike. `useRecord` is different: it honours
|
|
82
|
+
`enabled: false` and issues no request. (`useLinkedRecords`, `useMetric` and `useChartData` were
|
|
83
|
+
not probed — don't assume either behaviour for them.)
|
|
84
|
+
|
|
85
|
+
So `enabled` cannot make a list query conditional, and it cannot keep a query away from viewers
|
|
86
|
+
who should not run it. Two things that do work:
|
|
87
|
+
|
|
88
|
+
```jsx
|
|
89
|
+
// 1. Gate by MOUNT — put the hook in a child component and render it only when needed.
|
|
90
|
+
function CommentsSection({ orderId }) {
|
|
91
|
+
var comments = useRecords({ from: ds.comments, select: commentSelect, count: 50,
|
|
92
|
+
where: q.array("order").hasAllOf([orderId]) });
|
|
93
|
+
// …
|
|
94
|
+
}
|
|
95
|
+
// in Block(): {canSeeComments && <CommentsSection orderId={recordId} />}
|
|
96
|
+
|
|
97
|
+
// 2. Keep the hook mounted but give it a match-nothing `where` until it should load.
|
|
98
|
+
var rows = useRecords({ select: select, count: 50,
|
|
99
|
+
where: q.text("email").is(email || "__no_match__") });
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Option 1 is the only one that sends no request at all; option 2 still calls the endpoint and gets
|
|
103
|
+
zero rows back. Remember the child must be defined at **module scope** (SKILL.md Self-validate).
|
|
104
|
+
|
|
69
105
|
### Loading All Records (Auto-Pagination)
|
|
70
106
|
|
|
71
107
|
```jsx
|
|
@@ -85,14 +121,45 @@ useEffect(function() {
|
|
|
85
121
|
```jsx
|
|
86
122
|
import { useRecord, useCurrentRecordId, q } from "@/lib/datasource";
|
|
87
123
|
|
|
88
|
-
var
|
|
124
|
+
var detailSelect = q.select({ title: "FIELD_ID1", description: "FIELD_ID2" });
|
|
125
|
+
|
|
126
|
+
var recordId = useCurrentRecordId(); // the URL's `recordId` param — can be null
|
|
89
127
|
var result = useRecord({
|
|
90
|
-
select:
|
|
128
|
+
select: detailSelect,
|
|
91
129
|
recordId: recordId,
|
|
130
|
+
enabled: !!recordId, // honoured by useRecord: no id → no request
|
|
92
131
|
});
|
|
132
|
+
|
|
133
|
+
var record = result.data && result.data.id === recordId ? result.data : null; // trust only a matching id
|
|
93
134
|
```
|
|
94
135
|
|
|
95
|
-
|
|
136
|
+
**There is NO detail-page auto-scoping (verified live 2026-09-18, Softr Database, network
|
|
137
|
+
capture).** The runtime sends `pageContext: null` with the block's data requests — nothing tells
|
|
138
|
+
the server which record the page is "about". Consequences:
|
|
139
|
+
|
|
140
|
+
- `useRecords({ count: 1 })` on a detail page returns the **FIRST row of the table**, not the
|
|
141
|
+
URL's record. It looks right on the first record you test and wrong on every other.
|
|
142
|
+
- `useCurrentRecordId()` **does** return the URL's `recordId`, and
|
|
143
|
+
`useRecord({ from, select, recordId })` fetches exactly that record (it hits `/records/<id>`).
|
|
144
|
+
That is the detail-page pattern — the only one.
|
|
145
|
+
- `useRecord` with a **null / missing id falls back to a list call** and hands back whatever that
|
|
146
|
+
returns. So always pass `enabled: !!recordId` (`useRecord` honours `enabled: false` — no
|
|
147
|
+
request is made) and verify `data.id === recordId` before rendering or, worse, writing.
|
|
148
|
+
|
|
149
|
+
**A recordId-less `useRecord` — what the older note here meant, and its limits.** This file used
|
|
150
|
+
to say that `useRecord({ select })` with no `recordId` "loads the record the block is bound to
|
|
151
|
+
via its data-source binding in Studio" (seen on one deployed Airtable-backed stats block, July
|
|
152
|
+
2026, which rendered live values that way). Read that in the light of the capture above: no
|
|
153
|
+
record context is sent, and a null-id `useRecord` falls back to a list call — so the likeliest
|
|
154
|
+
explanation of the July block is that it was showing the list fallback's row, which is the
|
|
155
|
+
"right" record only when the connection's Source conditions/sort leave exactly that row first.
|
|
156
|
+
(That reading is an inference: the Airtable block was not re-probed, and the 2026-09-18 capture
|
|
157
|
+
was on Softr Database.) Either way it is not a binding you can rely on, and never the way to
|
|
158
|
+
build a detail page. The review corollary
|
|
159
|
+
survives in a narrower form: a recordId-less `useRecord` in a **working, deployed** block is not
|
|
160
|
+
by itself proof of a defect — check what it actually loads (and for which viewers) before
|
|
161
|
+
flagging it, and when editing such a block, know that adding an explicit `recordId` changes what
|
|
162
|
+
it loads on pages whose URL carries no `recordId` param.
|
|
96
163
|
|
|
97
164
|
## useLinkedRecords -- Fetch Linked/Related Options
|
|
98
165
|
|
|
@@ -189,6 +256,39 @@ where: q.and(
|
|
|
189
256
|
)
|
|
190
257
|
```
|
|
191
258
|
|
|
259
|
+
### Filtering by a linked record (server-side)
|
|
260
|
+
|
|
261
|
+
*Verified live 2026-09-18 (Softr Database, network capture).* A linked-record field filters on
|
|
262
|
+
the server with the array builder and the linked record's id — no need to load the whole child
|
|
263
|
+
table and filter client-side:
|
|
264
|
+
|
|
265
|
+
```jsx
|
|
266
|
+
var commentSelect = q.select({ order: "LINK_FIELD_ID", body: "FIELD_ID2" });
|
|
267
|
+
|
|
268
|
+
var comments = useRecords({
|
|
269
|
+
from: ds.comments,
|
|
270
|
+
select: commentSelect,
|
|
271
|
+
count: 50,
|
|
272
|
+
where: q.array("order").hasAllOf([orderId]), // "order" = the link field's ALIAS
|
|
273
|
+
});
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
On the wire the alias is resolved to the field id:
|
|
277
|
+
`{ subject: <fieldId>, type: "ARRAY", operator: "HAS_ALL_OF", value: [orderId] }`. Alias → field
|
|
278
|
+
attribution is **per datasource**, so two selects on different connections may use the same alias
|
|
279
|
+
name for different fields and each filter still resolves against its own connection.
|
|
280
|
+
|
|
281
|
+
`orderId` must be a real id when the hook runs — `useRecords` cannot be switched off with
|
|
282
|
+
`enabled: false` ([above](#userecords-ignores-enabled-false)), so mount this query in a child
|
|
283
|
+
component that only renders once the parent record has loaded.
|
|
284
|
+
|
|
285
|
+
**Reading the link back:** a linked field can arrive as a **single `{ id, label }` object**, not
|
|
286
|
+
only as an array of them. Normalise before you `.map()` or compare ids:
|
|
287
|
+
|
|
288
|
+
```jsx
|
|
289
|
+
var links = Array.isArray(v) ? v : (v ? [v] : []);
|
|
290
|
+
```
|
|
291
|
+
|
|
192
292
|
## Sorting
|
|
193
293
|
|
|
194
294
|
```jsx
|
package/datasources/writing.md
CHANGED
|
@@ -31,6 +31,29 @@ implication: do the Actions-tab tightening pass only AFTER the last redeploy of
|
|
|
31
31
|
re-check every tightened block after any future redeploy. Hit across a 15-block production
|
|
32
32
|
deployment; treat it as standing platform behavior, not a one-off.
|
|
33
33
|
|
|
34
|
+
### Actions register per TABLE, not per hook or connection
|
|
35
|
+
|
|
36
|
+
*Verified live 2026-09-18 (Softr Database; probe block + network capture in a draft preview).*
|
|
37
|
+
|
|
38
|
+
- **Several `useRecordUpdate` hooks on one table merge into ONE `UPDATE_RECORD` action** whose
|
|
39
|
+
field list is the UNION of all their `fields:` selects. Splitting a table's writes across hooks
|
|
40
|
+
("one hook for the status, one for the admin-only fields") does not produce separately
|
|
41
|
+
permissionable actions — there is one action, and one visibility setting, per table and operation.
|
|
42
|
+
(What follows from that, deduced rather than separately tested: two user groups needing
|
|
43
|
+
different write rights on the same table cannot be expressed inside one block — use a second,
|
|
44
|
+
group-gated block, or a Softr Workflow that does the privileged write.)
|
|
45
|
+
- **When the same table is connected twice** (the private-field pattern in
|
|
46
|
+
[multi-datasource.md](multi-datasource.md#one-connection--one-read-payload-the-union-of-its-selects)),
|
|
47
|
+
a mutation hook pointed at the SECOND connection was still filed under the FIRST connection's
|
|
48
|
+
`dataSourceId`. **Point writes at the table's first connection** and keep the second one
|
|
49
|
+
read-only, so the code says what the platform does.
|
|
50
|
+
- A mutation hook's `fields:` select does **not** join the connection's read union — write-only
|
|
51
|
+
fields are not shipped to the browser by the records endpoint.
|
|
52
|
+
|
|
53
|
+
Practical upshot for the post-push permission pass (see
|
|
54
|
+
[softr-mcp.md](../references/softr-mcp.md#the-array-argument-rejection-and-why-it-is-a-security-issue)):
|
|
55
|
+
expect one row per table + operation, and re-tighten that row.
|
|
56
|
+
|
|
34
57
|
The `enabled` boolean on a mutation hook is a combined signal — it's `true` only when BOTH conditions are met:
|
|
35
58
|
|
|
36
59
|
1. **The Action was successfully derived from the code** (parser side). Causes of failure here:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "softr-vibe-coding",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.9.1",
|
|
4
4
|
"description": "Claude Code skill for generating production-ready Softr Vibe Coding blocks (JSX). Installs into ~/.claude/skills/ and auto-updates on each Claude Code session.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"softr-vibe-coding": "./bin/cli.js"
|
|
@@ -19,6 +19,10 @@ Run through this catalog before delivering any block. Every row is a violation o
|
|
|
19
19
|
| Omitting `from:` on a hook when the block has more than one datasource | Throws at runtime. `from:` is optional ONLY when exactly one source is connected — then hooks default to it. Applies to `useRecords`, `useRecord`, `useLinkedRecords`, `useFieldOptions`, `useMetric`, `useChartData`, `useRecordCreate`, `useRecordUpdate`, `useRecordDelete`. NOT to `useUpload` / `useCurrentRecordId`, which are app-level. `useProxyFetch` has the same multi-datasource requirement but takes the alias as its **argument** — `useProxyFetch(ds.store)` — not as `from:` |
|
|
20
20
|
| Hoisting datasource ids into constants: `datasource.define({ people: PEOPLE_DS_ID })` | Fails to compile — *"datasource.define() object values must be string literals."* Softr statically analyses the call, same as `q.select()`. Keep the UUIDs **inline**: `datasource.define({ people: "74d2cbfd-…" })`. Fails fast with an explicit message, but hoisting magic strings is a strong reflex — resist it here |
|
|
21
21
|
| Asking Studio's AI chat "what are the datasource IDs?" and pasting the answer | **It fabricates them.** Verified July 2026: asked three times for the same three connected tables, it gave three different UUID sets, once reusing a previously-mentioned table's uuid for a different table — all confidently worded, none hedged. Ask it to **write code** instead (*"write a datasource.define call covering every connected source, plus one useRecords per source, code only"*) — scaffolding is bound to the real connections. Then RUN it: real rows under each heading proves each alias maps where you think. A wrong uuid fails safe (matches nothing → error); a *swapped pair* of valid uuids does not |
|
|
22
|
+
| "Hiding" a private field from non-admins with a second `q.select`, or a ternary between a public and an admin select, on the SAME connection | **Not privacy.** The records endpoint is per block + connection (`/blocks/<id>/datasources/<dsId>/records`) and returns the UNION of every field named by any READ `q.select` on that connection — every viewer's browser receives the private field; it is merely not rendered (verified live 2026-09-18, network capture). A mutation hook's `fields:` select does not join the union. Fix: connect the **same table a second time** (allowed — it gets its own dataSourceId), read the private field only from that connection in a hook that non-privileged browsers never run (a child component mounted only for admins), or move it to a group-gated block. Server-side, page VIEW permission gates the endpoint and Source conditions gate ROWS; nothing else does. See [datasources/multi-datasource.md](../datasources/multi-datasource.md#one-connection--one-read-payload-the-union-of-its-selects) |
|
|
23
|
+
| `select: isAdmin ? adminSelect : publicSelect` (or an inline `q.select({...})` in the hook options) in a **multi-datasource** block | The select cannot be attributed to a connection and the query returns records with `fields: {}` — no compile error, no runtime error, just empty fields (verified live 2026-09-18). `select:` / `fields:` must be a **plain module-scope identifier**. In a single-datasource block the ternary "works", but as a union of both branches (row above) — so it is never the tool it looks like. See [datasources/multi-datasource.md](../datasources/multi-datasource.md#select-must-be-a-plain-module-scope-identifier) |
|
|
24
|
+
| `useRecords({ select, count: 1 })` on a detail page, expecting the page's record — or a `useRecord` with no / null `recordId` | There is **no detail-page auto-scoping**: the runtime sends `pageContext: null`, so `count: 1` returns the table's FIRST row, and a null-id `useRecord` falls back to a list call (verified live 2026-09-18). It passes a test on the first record and fails on every other. Use `useRecord({ from, select, recordId, enabled: !!recordId })` with `recordId = useCurrentRecordId()` (which does return the URL's `recordId`; the call hits `/records/<id>`), and verify `data.id === recordId` before rendering or writing. See [datasources/reading.md](../datasources/reading.md#userecord----fetch-a-single-record) |
|
|
25
|
+
| `useRecords({ ..., enabled: false })` / `enabled: someFlag` to defer or withhold a list query | **`useRecords` ignores `enabled: false`** — literal or variable, it fetches anyway (verified live 2026-09-18). `useRecord` honours it. To make a list query conditional, mount the hook in a **child component rendered only when needed** (the only option that sends no request), or give it a match-nothing `where`. Never rely on `enabled` to keep a table away from viewers who should not load it. See [datasources/reading.md](../datasources/reading.md#userecords-ignores-enabled-false) |
|
|
22
26
|
|
|
23
27
|
## Mutations
|
|
24
28
|
|
|
@@ -40,6 +44,7 @@ Run through this catalog before delivering any block. Every row is a violation o
|
|
|
40
44
|
| Tightening Actions-tab permissions before the block's final redeploy | Every code recompile **resets the auto-registered Actions to default permissions** (verified live 2026-08-25). Tighten permissions after the LAST redeploy, and re-check after any future one |
|
|
41
45
|
| Assuming a comment-only edit is "safe" and leaves Action permissions alone | There is no cosmetic-edit exemption. Any save recompiles, and every recompile rebuilds the Actions at default visibility — a `search_replace` changing nothing but a code comment resets them exactly like a rewrite (verified live 2026-09-09, on two blocks at once). Re-check after EVERY push, including cosmetic ones |
|
|
42
46
|
| Treating the permission-restore call as done because you issued it | Read the permissions back with `get_vibe_coding_block_settings` and confirm each one changed. `set_vibe_coding_block_action_visibility` can fail outright on the array-argument serialization quirk, and it has **no fallback** — the default for a `genericActions` ADD_RECORD is `ALL_USERS`, so a routine push silently leaves the block publicly writable while returning `errors: null`. Verified live 2026-09-09: four ADD_RECORD actions left open across two blocks. Report the list with its severity — check the page's VIEW permission with `get_page_permissions`, since a logged-in-gated page makes this housekeeping while a public page makes it a real hole — and let the builder decide whether it holds their release. A human sets them on the block's Actions tab |
|
|
47
|
+
| Splitting one table's writes across several `useRecordUpdate` hooks (or across two connections of the same table) to get separately-permissioned Actions | Actions register per **TABLE**: the hooks merge into ONE UPDATE_RECORD action whose field list is the union, and a hook pointed at a second connection of the table is still filed under the FIRST connection's dataSourceId (verified live 2026-09-18). Point writes at the table's first connection; expect one action per table + operation when re-tightening permissions. See [datasources/writing.md](../datasources/writing.md#actions-register-per-table-not-per-hook-or-connection) |
|
|
43
48
|
| Treating Studio's Actions tab as a separately-managed configuration to keep in sync with code | Actions auto-derive from your `useRecordCreate`/`useRecordUpdate`/`useRecordDelete` + `q.select` on every save. The Actions tab is a read-only inspector; there is no manual delete control. To change an Action, change the code |
|
|
44
49
|
| One alias in a write-side `q.select` referencing a renamed / non-existent Airtable column | Softr's Action parser silently rejects the **entire** create/update Action — not just the bad alias. Symptoms: Studio's Actions tab shows "No actions used in this block yet", `createRecord.enabled` / `updateRecord.enabled` stays `false`, `.mutate()` calls dispatch but resolve immediately to "not yet ready". Every OTHER field in the same `q.select()` is also lost, even the ones that map cleanly. Diagnostic: bisect the `q.select` — strip down to a known-good minimal set, confirm the Action appears in Studio, then add fields back in halves until it drops out. The culprit is in the last half added. Once narrowed to a single field, grep its name against the freshest Airtable schema export to catch the rename / trailing-space / case-mismatch. Verified 2026-05-21: a `"Photos"` column on Wigs was renamed to `"Before Photos"`, the helper that wrote `photos: "Photos"` had its entire Action disabled even though 11 other fields in the same `q.select` were fine. See [datasources/airtable.md](../datasources/airtable.md#maintainability-gotcha) |
|
|
45
50
|
|
|
@@ -69,6 +74,8 @@ Run through this catalog before delivering any block. Every row is a violation o
|
|
|
69
74
|
| Placing a `<NavigationAction navigation={{ action: "OPEN_CHAT" }}>` Ask-AI button on a block that has no data source connected | Connect the block to the data source the AI should read from in Studio's Source tab. Softr's AI pulls context from the **block that triggered the chat**, not from the page — a button-only helper block with no data source causes `chat/prepare` → HTTP 500 ("Failed to prepare AI assistant") even though the chat UI opens fine. The block doesn't need to read or write records itself; the connection is purely for AI context. Verified by direct experiment, May 2026 |
|
|
70
75
|
| Emojis in UI | lucide-react icons only |
|
|
71
76
|
| `document.elementFromPoint(x, y)` to hit-test during a drag or custom pointer interaction | It returns the block's shadow **host**, not the element under the cursor — same boundary that stops `getElementById` and URL-fragment lookup. Either call it on the shadow root (`ref.current.getRootNode().elementFromPoint(x, y)`) or, better, keep refs to the candidate elements and compare `getBoundingClientRect()` yourself: rects need no shadow-root plumbing and work identically when the block is later reused elsewhere |
|
|
77
|
+
| `overflow-hidden`, `truncate`, `line-clamp-*` or `overflow-*-auto` on a container that holds a dropdown or popover — typically a `<td>` clipped so an over-wide status chip stops at its own column | **Symptom:** the menu opens cut to the height of its row or its scroller: one or two options showing, the rest unreachable by mouse. **Cause:** the `Combo` panel is `position: absolute` in the block's own DOM (a portal would leave the shadow root and lose its styles), and an absolutely positioned box is clipped by every ancestor whose `overflow` is not `visible`. `truncate` and `line-clamp-*` set `overflow: hidden`; `overflow-x-auto` turns `overflow-y` to `auto` as well. **Fix:** no clipping class between the Combo and the scroller it belongs to; bound the chip at the chip (`min-w-0 truncate` on the chip inside the flex trigger); measure the drop-up and the list height against the clipping ancestors, not the window. Hit in production 2026-09-30: three ROSIE item tables clipped the status cell as a 2px backstop, next to a comment claiming the menu was portaled — it had stopped being portaled when the tables moved from shadcn `<Select>` to `Combo`. See [searchable-dropdown.md](searchable-dropdown.md#the-four-things-that-will-bite-you), item 4 |
|
|
78
|
+
| `el.scrollIntoView({ block: "nearest" })` to keep a dropdown's highlighted option in view (or a plain `focus()` on its search box) | **Symptom:** the table or the page jumps when a menu opens near an edge; inside a clipped cell the trigger itself scrolls out of view. **Cause:** `scrollIntoView` scrolls EVERY scrollable ancestor until the element shows, and `overflow: hidden` boxes are still scrollable from script; `focus()` scrolls ancestors the same way. **Fix:** scroll the list element only — compare the option's rect with the list's and adjust `list.scrollTop` — and focus with `{ preventScroll: true }`. Verified in Chromium 2026-09-30: `scrollIntoView` scrolled an `overflow: hidden` cell by 164px, the list-only scroll moved nothing outside the list. See [searchable-dropdown.md](searchable-dropdown.md#the-four-things-that-will-bite-you), item 4, rule 3 |
|
|
72
79
|
| Positioning repeated page chrome (back button, title, primary action) per-block, without checking the pages that already have it | Chrome the user meets on more than one screen is a cross-page contract. Copy the exact offset from the blocks that already ship it, and change every page in one edit. Let the wrapper's padding be the only thing positioning it — `mb-4` and NO top margin on a back button — so one number per page governs it. Verified 2026-09-09: an extra `mt-6` sat one detail page's back button 24px lower than another's, and **each block looked correct in isolation**. See SKILL.md's Block Placement section |
|
|
73
80
|
| A loading skeleton carrying a different border / offset from the component it stands in for | The skeleton must track the component's REST state (border colour, padding, chrome offsets), never its hover state. If they disagree the layout visibly re-draws the instant data lands — the exact thing a skeleton exists to prevent. Verified 2026-09-09 twice in one session: a card grid re-outlined itself on load, and a back button jumped 24px. See [ui-ux-guidelines.md](../ui-ux-guidelines.md) §12 |
|
|
74
81
|
| `focus-visible:` on a card or row that only *contains* buttons | The container is a plain `<div>` and never takes focus, so it is dead CSS. Use `focus-within:` on the container (pairs with its `hover:` treatment) and keep `focus-visible:ring-2` on the button/link itself |
|
|
@@ -515,4 +515,4 @@ function onKeyDown(e) {
|
|
|
515
515
|
|
|
516
516
|
Make the rows themselves focusable too (`tabIndex={0}`, an `onKeyDown` that opens on Enter only when `event.target === event.currentTarget`, so an Enter on the anchor inside the row is not handled twice), and let `onMouseEnter` *and* `onFocus` both move the highlight onto the row — the highlight is the single answer to "which record does Enter open", whichever device last touched it. That is the shape `projects-table.jsx` shipped on 2026-09-10.
|
|
517
517
|
|
|
518
|
-
Paint the highlighted row with the same colour the mouse hover gets (`data-active="true"` + `bg-[#FFF7EF]`) and scroll it into view when it moves (`querySelector('[data-active="true"]').scrollIntoView({ block: "nearest" })` in a `useEffect` on `activeIdx`)
|
|
518
|
+
Paint the highlighted row with the same colour the mouse hover gets (`data-active="true"` + `bg-[#FFF7EF]`) and scroll it into view when it moves (`querySelector('[data-active="true"]').scrollIntoView({ block: "nearest" })` in a `useEffect` on `activeIdx`). That is right here, because these rows are page content and the table's scroller and the page *should* move to them. Inside a dropdown it is wrong, and the Combo scrolls only its own list — see [searchable-dropdown.md](searchable-dropdown.md#the-four-things-that-will-bite-you), item 4, rule 3. Reset `active` to 0 whenever the query changes: the old index points at a row that may no longer be in the list. `autoFocus` is right only when the block *is* the page's reason to exist — an index page whose first act is always a search; on a page with content above the table, a focus steal scrolls the page to the box.
|
|
@@ -82,11 +82,12 @@ useRecords({
|
|
|
82
82
|
## Single Record (detail pages)
|
|
83
83
|
|
|
84
84
|
```jsx
|
|
85
|
-
var recordId = useCurrentRecordId();
|
|
86
|
-
var result = useRecord({ recordId: recordId, select: select });
|
|
85
|
+
var recordId = useCurrentRecordId(); // the URL's recordId — can be null
|
|
86
|
+
var result = useRecord({ recordId: recordId, select: select, enabled: !!recordId });
|
|
87
|
+
var record = result.data && result.data.id === recordId ? result.data : null;
|
|
87
88
|
```
|
|
88
89
|
|
|
89
|
-
`
|
|
90
|
+
There is **no detail-page auto-scoping** (verified live 2026-09-18): `useRecords({ count: 1 })` returns the table's FIRST row, and a null-id `useRecord` falls back to a list call — hence `enabled: !!recordId` (honoured by `useRecord`; **ignored by `useRecords`**) and the `data.id` check. The older "recordId may be omitted when Studio supplies the record context" note is qualified in [reading.md](../datasources/reading.md#userecord----fetch-a-single-record).
|
|
90
91
|
|
|
91
92
|
## Current User
|
|
92
93
|
|
|
@@ -8,13 +8,13 @@ the obvious choices:
|
|
|
8
8
|
|---|---|
|
|
9
9
|
| Native `<select>` | Hands the list to the OS. No keyword filter, none of your styling, and on macOS it paints a grey slab over the page. Fine for 5 options, unusable at 90. |
|
|
10
10
|
| shadcn `<Select>` / `<Command>` | **Portals to `document.body`, which is outside the block's shadow root**, so the styles arrive stripped. It also cannot be searched. |
|
|
11
|
-
| `Combo` (below) | Local DOM, brand-styled, keyword filter, A→Z, keyboard, create-new, drop-up. |
|
|
11
|
+
| `Combo` (below) | Local DOM, brand-styled, keyword filter, A→Z, keyboard, create-new, clip-aware drop-up. |
|
|
12
12
|
|
|
13
13
|
Copy the component into the block. A Vibe block is one self-contained file — there is no
|
|
14
14
|
shared module to import, so each block carries its own copy. Keep one canonical copy in the
|
|
15
15
|
project (e.g. `Assets/Softr App/Shared/combo.jsx`) and port changes from there.
|
|
16
16
|
|
|
17
|
-
## The
|
|
17
|
+
## The four things that will bite you
|
|
18
18
|
|
|
19
19
|
**1. Click-outside must use `composedPath()`, not `contains()`.**
|
|
20
20
|
By the time a click reaches `document`, the shadow DOM has *retargeted* its `target` to the
|
|
@@ -44,6 +44,182 @@ single most common Vibe Coding bug and it looks like a platform fault.
|
|
|
44
44
|
**3. `onMouseDown` on a row must `preventDefault()`.**
|
|
45
45
|
Otherwise focus leaves the search input before the click resolves.
|
|
46
46
|
|
|
47
|
+
**4. The panel is clipped by any ancestor whose `overflow` is not `visible`.**
|
|
48
|
+
The panel is `position: absolute` inside the trigger's `relative` wrapper, in the block's own
|
|
49
|
+
DOM, because it cannot portal out of the shadow root (see *Why not portal* below). Its
|
|
50
|
+
containing block is that wrapper, so *every* ancestor whose `overflow` is `hidden`, `auto`,
|
|
51
|
+
`scroll` or `clip` clips it, however far up. In Tailwind that is `overflow-hidden`,
|
|
52
|
+
`overflow-auto`, `overflow-y-auto`, `overflow-x-auto` (an `overflow-x` of `hidden`, `auto` or
|
|
53
|
+
`scroll` turns `overflow-y: visible` into `auto`, so it clips vertically too), `truncate` (it
|
|
54
|
+
sets `overflow: hidden`) and `line-clamp-*` (so does that). A clipped `<td>` is the height of
|
|
55
|
+
its row, and the menu opens inside it. Three rules follow.
|
|
56
|
+
|
|
57
|
+
**Rule 1 — never put a clipping class on an element that contains a `Combo`:** a `<td>`, a
|
|
58
|
+
card, a flex cell, anything between the Combo and the scroller it belongs to. If a chip or
|
|
59
|
+
label inside the trigger can overflow, bound it at the chip: `truncate` (or `overflow-hidden`)
|
|
60
|
+
plus `min-w-0` on the chip itself. The trigger is a flex row, and `min-w-0` is what lets a flex
|
|
61
|
+
item shrink below its text — strictly redundant while the chip clips itself, load-bearing the
|
|
62
|
+
moment the ellipsis moves to a span inside it. Clipping at the chip clips the chip. Clipping at
|
|
63
|
+
the cell clips the menu.
|
|
64
|
+
|
|
65
|
+
```jsx
|
|
66
|
+
<td className="px-4">{/* no overflow class on the cell */}
|
|
67
|
+
<Combo
|
|
68
|
+
bare
|
|
69
|
+
triggerContent={<span className="min-w-0 truncate px-2" style={chipStyle}>{label}</span>}
|
|
70
|
+
…
|
|
71
|
+
/>
|
|
72
|
+
</td>
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**Rule 2 — decide the drop-up and the list's height against the clipping ancestors, not the
|
|
76
|
+
window.** `window.innerHeight - rect.bottom` cannot see scroll containers: a table with its own
|
|
77
|
+
`max-height` + `overflow: auto` scroller, a dialog body with `overflow-y-auto`, a horizontally
|
|
78
|
+
scrollable table wrapper. A row near the bottom of one of those opens its menu downward into
|
|
79
|
+
the scroller's hidden area while the window still has plenty of room. Measure inside the strip
|
|
80
|
+
the panel can actually paint into:
|
|
81
|
+
|
|
82
|
+
```jsx
|
|
83
|
+
var COMBO_LIST_MAX = 256; // the list's normal ceiling (max-h-64)
|
|
84
|
+
var COMBO_LIST_MIN = 120; // the floor: below this, accept a clip rather than a useless list
|
|
85
|
+
|
|
86
|
+
/* The strip of screen the panel can paint into: the viewport, cut down by every ancestor
|
|
87
|
+
that clips vertically. overflowY is enough on its own: an overflow-x of hidden, auto or
|
|
88
|
+
scroll already turns a visible overflow-y into auto in the computed style. */
|
|
89
|
+
function comboClipBox(node) {
|
|
90
|
+
var top = 0;
|
|
91
|
+
var bottom = window.innerHeight;
|
|
92
|
+
var el = node;
|
|
93
|
+
while (el && el !== document.body && el !== document.documentElement) {
|
|
94
|
+
// clientHeight is 0 for the boxes overflow does not apply to (inline, display: contents)
|
|
95
|
+
if (el.clientHeight > 0 && window.getComputedStyle(el).overflowY !== "visible") {
|
|
96
|
+
var r = el.getBoundingClientRect();
|
|
97
|
+
var t = r.top + el.clientTop; // the clip edge is the padding box: inside the border
|
|
98
|
+
var b = t + el.clientHeight; // and above a horizontal scrollbar
|
|
99
|
+
if (t > top) top = t;
|
|
100
|
+
if (b < bottom) bottom = b;
|
|
101
|
+
}
|
|
102
|
+
// Where the parent chain ends at the shadow root, carry on from its host.
|
|
103
|
+
el = el.parentElement || el.getRootNode().host || null;
|
|
104
|
+
}
|
|
105
|
+
return { top: top, bottom: bottom };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* chrome = the panel's height outside the list: the search box and the borders. */
|
|
109
|
+
function comboPlace(wrapper, chrome) {
|
|
110
|
+
var clip = comboClipBox(wrapper);
|
|
111
|
+
var r = wrapper.getBoundingClientRect();
|
|
112
|
+
var below = clip.bottom - r.bottom - 10; // the 2px gap to the trigger + 8px to spare
|
|
113
|
+
var above = r.top - clip.top - 10;
|
|
114
|
+
var up = below < chrome + COMBO_LIST_MAX && above > below;
|
|
115
|
+
var room = (up ? above : below) - chrome;
|
|
116
|
+
return { up: up, listMax: Math.max(COMBO_LIST_MIN, Math.min(COMBO_LIST_MAX, room)) };
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Call it when the panel opens, and give the list its height as an inline style in place of
|
|
121
|
+
`max-h-64`:
|
|
122
|
+
|
|
123
|
+
```jsx
|
|
124
|
+
var [listMax, setListMax] = useState(COMBO_LIST_MAX);
|
|
125
|
+
|
|
126
|
+
// in toggle(), before setOpen(true):
|
|
127
|
+
if (rootRef.current) {
|
|
128
|
+
var place = comboPlace(rootRef.current, searchable ? 51 : 2);
|
|
129
|
+
setDropUp(place.up);
|
|
130
|
+
setListMax(place.listMax);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// the list:
|
|
134
|
+
<div ref={listRef} role="listbox" className="overflow-y-auto py-1" style={{ maxHeight: listMax }}>
|
|
135
|
+
{/* rows */}
|
|
136
|
+
</div>
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Downward when the whole panel fits below, otherwise toward the larger side, with the list
|
|
140
|
+
capped to the room it gets. `chrome` is the panel's height outside the list — about 51px with
|
|
141
|
+
the search box (`p-2` around an `h-8` input, a 1px rule, the panel's two borders) and 2px
|
|
142
|
+
without; measure yours if the panel differs. The fit test assumes a full-height list, so a
|
|
143
|
+
three-option menu may flip up when it would have fit below, which costs nothing. The walk
|
|
144
|
+
starts at the wrapper, whose own overflow would clip the panel too; it leaves the shadow root
|
|
145
|
+
through its host, so a clipping container outside the block still counts; and it stops below
|
|
146
|
+
`<body>`, because `body` and `html` hand their overflow to the viewport — their computed
|
|
147
|
+
`overflow` can say `hidden` while they clip nothing. The four cases it has to get right: a row
|
|
148
|
+
mid-way down a tall table scroller opens down; the last visible row of a scroller whose bottom
|
|
149
|
+
edge is mid-window opens up, inside the scroller (the window-only rule opened it down, into
|
|
150
|
+
the hidden part); a picker at the bottom of a dialog body opens up, inside the dialog; a filter
|
|
151
|
+
row at the window's bottom edge opens up, as before. All four, plus a scroller outside the
|
|
152
|
+
shadow root, checked in Chromium on 2026-09-30 on a test page — not yet in a deployed block.
|
|
153
|
+
|
|
154
|
+
Rule 2 does not rescue a clipped cell. The cell is the height of its row, so neither side has
|
|
155
|
+
room, the list falls to its 120px floor and is clipped anyway. Rule 1 is not optional.
|
|
156
|
+
|
|
157
|
+
**The same box decides which edge the panel hangs from.** A panel hung from the trigger's left
|
|
158
|
+
edge with `width: max-content` runs past the right edge of a table's scroll box when the trigger
|
|
159
|
+
sits in the last column. In ROSIE the Location menu ran about 15px over, and the old
|
|
160
|
+
`scrollIntoView` then slid the whole table sideways to reveal it. Measure the box's left and right
|
|
161
|
+
edges as well (padding box, tested on `overflowX`), and when the room to the right of the trigger
|
|
162
|
+
is short (under ~300px) and there is more to the left, anchor the panel with `right: 0` instead of
|
|
163
|
+
`left: 0` and cap its `maxWidth` to the room on that side. Rows are a fixed height in this
|
|
164
|
+
component, so the fit test can also count the real rows instead of assuming a full list, and a
|
|
165
|
+
four-option menu near an edge stops flipping for room it will never use. ROSIE's
|
|
166
|
+
`Shared/combo.jsx` (2026-09-30) is the worked version: one `comboClipBox` returning all four
|
|
167
|
+
edges, one `comboPlacement` returning `{ up, right, listMax, maxW }`, deployed in twelve blocks.
|
|
168
|
+
|
|
169
|
+
**Rule 3 — keep the active row visible by scrolling the list, never with `scrollIntoView`.**
|
|
170
|
+
`scrollIntoView` scrolls *every* scrollable ancestor until the element shows, and an
|
|
171
|
+
`overflow: hidden` box is still scrollable from script. In a clipped cell it scrolls the
|
|
172
|
+
cell's content until the option shows, pushing the trigger out of view; near the edge of a
|
|
173
|
+
table it scrolls whatever the menu hangs out of — the table's own scroller, the page — so the
|
|
174
|
+
table jumps as the menu opens. Scroll the list element and nothing else:
|
|
175
|
+
|
|
176
|
+
```jsx
|
|
177
|
+
useEffect(
|
|
178
|
+
function () {
|
|
179
|
+
var list = listRef.current;
|
|
180
|
+
if (!open || !list) return;
|
|
181
|
+
var el = list.querySelector('[data-active="true"]');
|
|
182
|
+
if (!el) return;
|
|
183
|
+
var lr = list.getBoundingClientRect();
|
|
184
|
+
var er = el.getBoundingClientRect();
|
|
185
|
+
var viewTop = lr.top + list.clientTop; // inside the list's top border
|
|
186
|
+
var viewBottom = viewTop + list.clientHeight; // clientHeight excludes border and scrollbar
|
|
187
|
+
if (er.top < viewTop) list.scrollTop -= viewTop - er.top;
|
|
188
|
+
else if (er.bottom > viewBottom) list.scrollTop += er.bottom - viewBottom;
|
|
189
|
+
},
|
|
190
|
+
[open, activeIdx, query]
|
|
191
|
+
);
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Rects rather than `offsetTop`: a row's `offsetParent` is the nearest positioned ancestor,
|
|
195
|
+
which is the panel, not the list, so `offsetTop` comes out too large by the search box's
|
|
196
|
+
height unless the list is made `position: relative`. Rects need no such arrangement. The
|
|
197
|
+
effect runs after the commit, outside the event cycle Hard Constraint 17's `setTimeout` is
|
|
198
|
+
there to escape — the `scrollIntoView` it replaces ran from the same kind of effect and took
|
|
199
|
+
effect. `focus()` scrolls ancestors the same way, so focus the search box with
|
|
200
|
+
`inputRef.current.focus({ preventScroll: true })`.
|
|
201
|
+
|
|
202
|
+
**Why not portal the panel, or make it `position: fixed`?** A portal to `document.body`
|
|
203
|
+
leaves the shadow root, and the styles stay behind — the shadcn row at the top of this page.
|
|
204
|
+
`position: fixed` inside the shadow root escapes the clipping only while no ancestor has a
|
|
205
|
+
`transform`, `filter`, `perspective`, `contain` or `will-change`: any of those becomes the
|
|
206
|
+
containing block for fixed descendants, and scroll-reveal animations on Softr pages commonly
|
|
207
|
+
leave a transform behind (the same trap as the block-owned header in
|
|
208
|
+
[static-blocks.md](static-blocks.md#block-owned-landing-page-header)). A fixed panel also stops
|
|
209
|
+
moving with its trigger, so it has to be re-placed on every scroll. The clip-aware absolute
|
|
210
|
+
panel has neither problem, which is why it is the design here.
|
|
211
|
+
|
|
212
|
+
**The incident — ROSIE, 2026-09-30.** Three item tables put `overflow-hidden` on the `<td>`
|
|
213
|
+
holding the status chip, as a backstop so the chip, 1–2px too wide at the column's narrowest
|
|
214
|
+
drag width, would clip at its own column instead of painting over the next one. The comment
|
|
215
|
+
beside it said the status menu was portaled to the body, so the clip could not reach it. That
|
|
216
|
+
was true of the shadcn `<Select>` the tables used before and stopped being true when they moved
|
|
217
|
+
to `Combo`; nobody re-checked. The menu opened inside the 48px cell: one and a half options
|
|
218
|
+
showing, the chip scrolled out of view by `scrollIntoView`, the other six unreachable by
|
|
219
|
+
mouse. The Location dropdown in the same rows kept working, because its cell had no overflow
|
|
220
|
+
class. A 2px backstop cost the whole feature. A comment that says how a component renders is
|
|
221
|
+
a claim to verify in the component, not in the comment.
|
|
222
|
+
|
|
47
223
|
## Sort A→Z *inside* the component
|
|
48
224
|
|
|
49
225
|
Sorting at the call site gets forgotten. Do it in the component, with an opt-out:
|
|
@@ -125,16 +301,7 @@ than reaching past the wrapper.
|
|
|
125
301
|
Build `rows` as a single array — the optional *clear* row, then the filtered options, then
|
|
126
302
|
the optional *create* row — so arrow-key navigation has one index to walk. Clamp the active
|
|
127
303
|
index (`Math.min(active, rows.length - 1)`): filtering shrinks the list under the highlight.
|
|
128
|
-
|
|
129
|
-
## Drop up near the fold
|
|
130
|
-
|
|
131
|
-
```jsx
|
|
132
|
-
var r = rootRef.current.getBoundingClientRect();
|
|
133
|
-
var below = window.innerHeight - r.bottom;
|
|
134
|
-
setDropUp(below < 300 && r.top > below);
|
|
135
|
-
```
|
|
136
|
-
|
|
137
|
-
A filter row near the bottom of the viewport otherwise opens into nothing.
|
|
304
|
+
Keep the highlighted row in view by scrolling the list only (rule 3 of item 4 above).
|
|
138
305
|
|
|
139
306
|
## Variants worth having
|
|
140
307
|
|
|
@@ -145,6 +312,9 @@ A filter row near the bottom of the viewport otherwise opens into nothing.
|
|
|
145
312
|
⚠ If any column width in your table is derived from the trigger's chrome, keep the
|
|
146
313
|
chevron the SAME size in both variants. Shrinking it in `bare` silently changes those
|
|
147
314
|
widths in a different file.
|
|
315
|
+
⚠ A `bare` Combo sits in a table cell, which is exactly where `overflow-hidden` and
|
|
316
|
+
`truncate` get added. Keep them off the cell and bound the chip instead — rule 1 of item 4
|
|
317
|
+
in [The four things that will bite you](#the-four-things-that-will-bite-you).
|
|
148
318
|
- **`triggerStyle`** — merged over the defaults, for a trigger that is part of the design
|
|
149
319
|
(a chip painted in its own status colour) rather than a plain field.
|
|
150
320
|
- **`onCreate(text)`** — offers `Add "<typed>"` when nothing matches. If creating the record
|
|
@@ -172,7 +342,12 @@ Everything else — the trigger, the card, the rows — stays flat.
|
|
|
172
342
|
- [ ] Sorted A→Z inside the component, with `autoSort={false}` only where order is meaning
|
|
173
343
|
- [ ] Multi-token filter
|
|
174
344
|
- [ ] Searchable by default; `bare` is click-only; `searchable={false}` only on a short fixed enum the user is setting
|
|
175
|
-
- [ ]
|
|
345
|
+
- [ ] No overflow-clipping class (`overflow-hidden`, `overflow-*-auto`, `truncate`,
|
|
346
|
+
`line-clamp-*`) between the Combo and the scroller it belongs to; an over-wide chip
|
|
347
|
+
bounded at the chip (`min-w-0 truncate`)
|
|
348
|
+
- [ ] Drop-up and list `maxHeight` measured against the clipping ancestors, not the window
|
|
349
|
+
- [ ] Keyboard: ↑ ↓ Enter Esc Tab; the active row kept visible by scrolling the list only
|
|
350
|
+
(never `scrollIntoView`), and the search box focused with `preventScroll`
|
|
176
351
|
- [ ] `aria-haspopup="listbox"`, `aria-expanded`, `role="listbox"` / `role="option"`,
|
|
177
352
|
`aria-selected`, and an `aria-label` on the trigger
|
|
178
353
|
- [ ] Loading and empty states (`"Nothing matches that."`)
|
package/references/softr-mcp.md
CHANGED
|
@@ -13,10 +13,10 @@ The official Softr MCP server (`https://mcp.softr.io/mcp`) gives an AI assistant
|
|
|
13
13
|
- [What it covers](#what-it-covers)
|
|
14
14
|
- [Connection and auth](#connection-and-auth)
|
|
15
15
|
- [Permissions model](#permissions-model)
|
|
16
|
-
- [Vibe coding block tools](#vibe-coding-block-tools)
|
|
16
|
+
- [Vibe coding block tools](#vibe-coding-block-tools) — incl. [what the server enforces on a block's data endpoints](#what-the-server-enforces-on-a-blocks-data-endpoints)
|
|
17
17
|
- [Adopting Studio-AI-generated code](#adopting-studio-ai-generated-code)
|
|
18
18
|
- [Vibe coding gotchas (official)](#vibe-coding-gotchas-official)
|
|
19
|
-
- [Application management tools](#application-management-tools)
|
|
19
|
+
- [Application management tools](#application-management-tools) — incl. [testing as any user via "Preview as"](#testing-as-any-app-user-without-logins--the-preview-as-switcher)
|
|
20
20
|
- [Browsing integrations (external data sources)](#browsing-integrations-external-data-sources)
|
|
21
21
|
- [Softr Database tools](#softr-database-tools)
|
|
22
22
|
- [Workflows](#workflows)
|
|
@@ -96,6 +96,23 @@ not name, so **each block keeps its own pair and the swap step disappears entire
|
|
|
96
96
|
2026-09-09 across a report block deployed to two pages). It is also the safer option on large files:
|
|
97
97
|
retransmitting ~100KB verbatim to change one class string is its own corruption risk.
|
|
98
98
|
|
|
99
|
+
**Search-replace on a 100KB+ block — the working recipe (verified live 2026-09-18).** Sent a real
|
|
100
|
+
array of `{ search, replace }` objects (not a JSON string — see
|
|
101
|
+
[the array-argument rejection](#the-array-argument-rejection-and-why-it-is-a-security-issue)), the
|
|
102
|
+
tool patches large blocks reliably, and nothing but the fragments passes through the model's
|
|
103
|
+
context. Keep the local mirror in step mechanically rather than by hand:
|
|
104
|
+
|
|
105
|
+
1. Prove deployed == disk first ([below](#verifying-a-push--the-deployed-source-is-the-only-proof)).
|
|
106
|
+
2. Write the ops once, as data. Send them to the tool, and apply the **identical** ops to the local
|
|
107
|
+
mirror with a script that asserts each `search` occurs exactly once before replacing it.
|
|
108
|
+
3. Several rounds of ops are fine — **byte-verify once at the end**: fetch `sourceCode`, compare to
|
|
109
|
+
the mirror, and a mismatch means an op landed differently on one side.
|
|
110
|
+
|
|
111
|
+
One encoding trap: JSON `\uXXXX` escapes inside the ops are **decoded to the real characters** on
|
|
112
|
+
Softr's side (`"—"` is stored as `—`). The mirror must therefore hold raw UTF-8 — apply the
|
|
113
|
+
ops to it *after* JSON-decoding them, never as the escaped text, or the final byte comparison
|
|
114
|
+
fails on every non-ASCII character.
|
|
115
|
+
|
|
99
116
|
**Reach for the full replace when the change is structural** — reordering JSX, moving logic between
|
|
100
117
|
components, adding a hook — where being sure of "the exact current text" of a dozen scattered fragments
|
|
101
118
|
is harder than being sure of the whole file. Also use it when the local file is the source of truth and
|
|
@@ -121,7 +138,9 @@ unverified until you have pulled the source back down and compared it.
|
|
|
121
138
|
--log-level=error --outfile=/dev/null`) plus eslint with `@babel/eslint-parser`. The bugs that
|
|
122
139
|
actually bite Softr blocks are semantic — `useRecordUpdate({ select: … })` instead of `fields:`,
|
|
123
140
|
an invented identifier — and the push is the first thing that reports them.
|
|
124
|
-
3. Push the **entire** file
|
|
141
|
+
3. Push the **entire** file — or, for a targeted patch on a large block, send search-replace ops
|
|
142
|
+
and apply the identical ops to the mirror
|
|
143
|
+
([recipe above](#which-edit-tool-full-replace-vs-targeted-search-replace)).
|
|
125
144
|
4. **Fetch it back and compare again.** Identical, or you are not done: diff, fix, re-push.
|
|
126
145
|
|
|
127
146
|
**Compare byte for byte, trailing newline included.** Softr stores exactly what it receives: across
|
|
@@ -140,7 +159,7 @@ expectation (disk for the first, disk-with-swap for the second). Never save the
|
|
|
140
159
|
the local mirror — the mirror records which page it belongs to, and the block's header comment
|
|
141
160
|
records the other page's pair. Search-replace would avoid the swap altogether
|
|
142
161
|
([above](#which-edit-tool-full-replace-vs-targeted-search-replace)) — when the client can send its
|
|
143
|
-
array argument ([below](#the-array-argument-
|
|
162
|
+
array argument ([below](#the-array-argument-rejection-and-why-it-is-a-security-issue)).
|
|
144
163
|
|
|
145
164
|
**Do not read a 100KB block into a model's context to push it.** The full-replace tool takes the
|
|
146
165
|
whole file as a string parameter, so the source has to pass through whatever is making the call. A
|
|
@@ -178,8 +197,15 @@ shapes, different payload type. The stringification happened on the client side,
|
|
|
178
197
|
calls made while the tool definitions had not been loaded into the model's context (deferred
|
|
179
198
|
schemas), so there was no type to serialise against. **Load the tool's schema before calling it,
|
|
180
199
|
and pass arrays as arrays.** (Empty schemas are real on Softr's *per-application* MCP servers —
|
|
181
|
-
every tool there is advertised as `{"type":"object"}` with a name-only description
|
|
182
|
-
|
|
200
|
+
every tool there is advertised as `{"type":"object"}` with a name-only description.)
|
|
201
|
+
|
|
202
|
+
**Correction, 2026-09-30: the workspace server's tools can arrive schema-less too.** In one
|
|
203
|
+
session every workspace tool loaded through ToolSearch showed only `{"type":"object"}` with a
|
|
204
|
+
name-only description, and a `search_replace` call written with a real array was still sent as a
|
|
205
|
+
string and rejected with the error above. Nothing was written, so the failure is safe, but no
|
|
206
|
+
amount of care on the caller's side gets an array through a schema-less tool. Look at the
|
|
207
|
+
loaded schema before relying on an array argument: if it has no `properties`, go straight to the
|
|
208
|
+
fallback in the table — a full replace, one file per subagent for a large block, byte-verified.
|
|
183
209
|
|
|
184
210
|
**Why the second row is a security problem, not an inconvenience.** Every code push resets the
|
|
185
211
|
block's auto-registered Actions to Softr's defaults, and the default for a `genericActions`
|
|
@@ -211,10 +237,15 @@ one push left four ADD_RECORD actions open across two blocks.
|
|
|
211
237
|
block the publish.** It is not your app, and the person whose app it is needs the finding and the
|
|
212
238
|
severity, not a veto.
|
|
213
239
|
|
|
214
|
-
One caveat worth stating: page visibility and action permissions are *separate* gates
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
240
|
+
One caveat worth stating: page visibility and action permissions are *separate* gates. Page VIEW
|
|
241
|
+
**is** enforced on the block's datasource **records** endpoint (verified live 2026-09-18 — a
|
|
242
|
+
viewer who cannot view the page gets a 403 whose message names "block/action visibility rules";
|
|
243
|
+
see [below](#what-the-server-enforces-on-a-blocks-data-endpoints)). The *action* (write) endpoint
|
|
244
|
+
was not exercised separately; the message wording suggests the same gate covers it, but that part
|
|
245
|
+
is inference. So the reason to treat a gated page as low-severity is still the practical
|
|
246
|
+
difficulty and low blast radius — and note that "gated to logged-in users" keeps out anonymous
|
|
247
|
+
visitors only: any logged-in user can view that page, and therefore reach its endpoints. Say that
|
|
248
|
+
plainly rather than implying the action is safe.
|
|
218
249
|
|
|
219
250
|
**Calibration matters.** This guidance read "do not publish" in v2.5.1 and immediately fired at
|
|
220
251
|
maximum severity on a logged-in-gated app where the real exposure was junk records. A warning that
|
|
@@ -228,6 +259,34 @@ reverts the code along with the permissions, undoing the change you just pushed.
|
|
|
228
259
|
|
|
229
260
|
Both edit paths recompile, so both reset Action permissions either way (Hard Constraint 21).
|
|
230
261
|
|
|
262
|
+
### What the server enforces on a block's data endpoints
|
|
263
|
+
|
|
264
|
+
*Verified live 2026-09-18 (Softr Database; draft preview, "Preview as" different users, requests
|
|
265
|
+
captured from the app iframe).* A block's data lives behind per-connection endpoints —
|
|
266
|
+
`/blocks/<blockId>/datasources/<dataSourceId>/records` for lists, `/records/<id>` for one record —
|
|
267
|
+
and these are the gates that actually exist on them:
|
|
268
|
+
|
|
269
|
+
| Gate | Enforced server-side? |
|
|
270
|
+
|---|---|
|
|
271
|
+
| **Page VIEW permission** | **Yes.** A viewer who cannot view the page gets **403** ("block/action visibility rules…") from the block's datasource endpoint — crafting the request by hand does not get around it |
|
|
272
|
+
| **The connection's Source conditions** (Source tab / `set_vibe_coding_block_data_source_record_filters`) | **Yes — and they are the only server-side ROW gate** |
|
|
273
|
+
| A `where` filter in the block's code | No — it is a request parameter the caller controls |
|
|
274
|
+
| Which fields the block *renders*, a second / conditional `q.select`, `enabled: false` on `useRecords` | No — the endpoint returns the union of the connection's read selects to anyone allowed to call it (see [multi-datasource.md](../datasources/multi-datasource.md#one-connection--one-read-payload-the-union-of-its-selects)) |
|
|
275
|
+
|
|
276
|
+
The consequence to design around: **on a page any logged-in user may view, every datasource
|
|
277
|
+
connected to its blocks is readable by any logged-in user who crafts the request** — all rows the
|
|
278
|
+
Source conditions allow, all fields the block's read selects name. A per-record access check in
|
|
279
|
+
React ("is this viewer a party to this record?") shapes the UI; it is not access control. When rows
|
|
280
|
+
must be private per user, put it in the Source conditions (e.g. a logged-in-user condition) or on a
|
|
281
|
+
page only the right group can view. When a field must be private, a second connection of the table
|
|
282
|
+
keeps it out of every ordinary browser's payload — but not away from a crafted request by someone
|
|
283
|
+
who may view the page; for that it has to live on a page (or in a group-gated block) the viewer
|
|
284
|
+
cannot see. (The verified 403 case was page VIEW; the message's "block/action visibility rules"
|
|
285
|
+
wording suggests block visibility is checked the same way, which is inference.)
|
|
286
|
+
|
|
287
|
+
This is also what makes the open-`ADD_RECORD` finding above severity-dependent on the page's VIEW
|
|
288
|
+
permission rather than uniformly critical.
|
|
289
|
+
|
|
231
290
|
## Adopting Studio-AI-generated code
|
|
232
291
|
|
|
233
292
|
When you pull a Studio-AI-generated block via `get_vibe_coding_block_code` to adopt into a project repo as source of truth: its output renders fine but ships with predictable defects. **Functional patterns in Studio output are platform-support evidence** (it surfaces undocumented capabilities before the docs do — see SKILL.md's "Platform truth sources"); **its code hygiene is not a pattern to imitate.** Cleanup pass before committing:
|
|
@@ -268,6 +327,37 @@ Combined with the database tools (`create_database` / `create_table` / `create_f
|
|
|
268
327
|
|
|
269
328
|
> **preview_app links are auth tokens.** Per the server's own instructions, a preview link **signs its opener in as the user who requested it** and lasts about a day. Give it only to that user, and mint a fresh one with another `preview_app` call rather than re-sending an old link. Never paste a preview link into a shared channel.
|
|
270
329
|
|
|
330
|
+
### Testing as any app user without logins — the "Preview as" switcher
|
|
331
|
+
|
|
332
|
+
*Verified live 2026-09-18.* The `preview_app` link does not open the app directly: it opens a
|
|
333
|
+
**toolbar shell** with a **"Preview as" user switcher**, and runs the draft app in an **iframe**
|
|
334
|
+
whose URL carries `?autoUser=true`. That is a complete role-testing rig — every user group, no
|
|
335
|
+
passwords, no test accounts to create:
|
|
336
|
+
|
|
337
|
+
- The switcher is a **Choices.js** select listing the app's users. Picking one raises a
|
|
338
|
+
confirmation modal ("Ok, I understand"); after confirming, the app runs as that user, and the
|
|
339
|
+
choice persists per browser.
|
|
340
|
+
- **With the Browser pane visible**, click through it like a person would.
|
|
341
|
+
- **With the pane hidden, drive it by script** in the shell page: dispatch `mouseover` then
|
|
342
|
+
`mousedown` on `.choices__item--choice[data-value="<email>"]` (Choices.js acts on
|
|
343
|
+
`mousedown`, not `click`), then click
|
|
344
|
+
`#userConfirmationModal button.submit-btn`.
|
|
345
|
+
- **To see what a block really sends and receives**, set `iframe.src` to the page under test and
|
|
346
|
+
wrap `iframe.contentWindow.fetch` immediately afterwards, recording each request body and
|
|
347
|
+
response. This is how the union-of-selects, `pageContext: null` and `enabled: false` findings in
|
|
348
|
+
[../datasources/](../datasources/) were established — read the wire, not the rendered UI.
|
|
349
|
+
- Blocks render in shadow roots inside that iframe: read the DOM through
|
|
350
|
+
`iframe.contentDocument` and each block host's `shadowRoot`, not `document.querySelector`.
|
|
351
|
+
|
|
352
|
+
> **The preview is wired to the LIVE datasource.** Anything clicked there — a Save, a status
|
|
353
|
+
> change, a form submit — writes real records, as the previewed user. Keep preview sessions to
|
|
354
|
+
> read-only checks unless the record is a marked test record, and never run a write path "just to
|
|
355
|
+
> see" against client data.
|
|
356
|
+
|
|
357
|
+
Limits: a page gated to a user group nobody belongs to cannot be previewed until someone is in the
|
|
358
|
+
group, and these selectors are Softr's shell internals — observed, not documented, so re-inspect
|
|
359
|
+
the shell if a selector stops matching rather than assuming the feature is gone.
|
|
360
|
+
|
|
271
361
|
## Browsing integrations (external data sources)
|
|
272
362
|
|
|
273
363
|
An integration is an external data source connected once per workspace (the builder says "integrations", the tools say "data sources" — same thing). Five read-only tools drill down from workspace to fields; each level needs an ID from the level above:
|