Skip to content

fix(console): /forms/:name honours ?recordId= — load the record, prefill it, PATCH it (#4278) - #4293

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4278-form-edit-recordid
Aug 11, 2026
Merged

fix(console): /forms/:name honours ?recordId= — load the record, prefill it, PATCH it (#4278)#4293
yinlianghui merged 1 commit into
mainfrom
claude/issue-4278-form-edit-recordid

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#4278

Builds directly on #4109 / PR #4279 (resolveSubmitBehavior, readCreatedRecordId, InternalFormRoute), whose squash 90e792e11 is this branch's base.

The defect

ActionRunner.executeForm forwards the record an action was fired from as /forms/:name?recordId=..., but the internal form route never read that param — it consumed only the prefill_ ones. So the route rendered empty inputs, and submitInternal was an unconditional POST /api/v1/data/:object, i.e. an INSERT. An "edit this record" action opened a blank form and, on Submit, created a second record while leaving the original untouched. In the showcase app: open any Task, click Log Time, fill it in, Submit — a new Task appears.

What this does

?recordId= now selects the whole read/write pair, per PM ruling point 1 (implement the read side on this route; do not reroute to the app-shell record-form surface, do not touch ActionRunner):

  • GET /api/v1/data/:object/:id to load, inputs prefilled with the stored values;
  • PATCH /api/v1/data/:object/:id to save;
  • and the user lands back on the record they edited.

The update convention was measured, not chosen

PATCH /:object/:idupdateData is the data plugin's declared route (@objectstack/spec, api/plugin-rest-api.zod.ts), it is what packages/rest's server registers, and packages/rest/src/openapi-builtin-paths.ts records that the server "answers PATCH and 405s the PUT" — a published document that said PUT was itself filed as a defect (objectstack#5588). Every other update client in this workspace spells the same pair: ApiDataSource.update (packages/core), the console's own API-discovery and Integrations pages, and app-shell's ObjectApiPanel. The body is the bare field patch, matching create.

The read side is GetDataResponse = { object, id, record }, also spec-declared. The { success, data } transport envelope is absorbed by one extracted helper that both the create and the read path share, mirroring @objectstack/client.unwrapResponse rather than spelling a second rule.

Fail closed on a bad recordId (ruling point 2)

A 403/404, a payload whose object contradicts the form's target, and a present-but-blank ?recordId= each render the form's error state. None degrades to create mode: a blank form whose submit inserts a duplicate is this bug's exact harm, so silently falling back into it would re-arm the defect. A recordId belonging to a different object is not found under the form's own object, so it 404s onto the same refusal path.

Honest reachability note on the object-mismatch guard: the deployed server echoes the path object (getData returns object: request.object) and we build that path from the view's target, so a live packages/rest deployment cannot produce a mismatching payload. That guard is a contract assertion at the consumer, pinned with a hand-built body — not a live defect being patched. The reachable half of "wrong object" is the 404, and it is pinned as such.

Prefill precedence, pinned

Explicit prefill_ params win for the fields they name; the stored record fills the rest; a field's create-time defaultValue is lowest. Precedence is per field, never wholesale — a producer forwarding both ?recordId= and ?prefill_x= is expressing intent ("edit THIS record, with THIS field pre-changed"), and the narrower instruction is the more specific one. A stored null or empty string counts as a real value and beats a default, so opening an edit form never silently proposes a change the user did not make.

Where an edit lands

The destination is the id from the URL — the row we just PATCHed — not one read back off the response. UpdateDataResponse was measured and declares the identical { object, id, record, droppedFields? } triple as create, so readCreatedRecordId would fit byte for byte; it is deliberately not pointed at one, because the edit path already holds that fact and a second reader could only ever disagree by way of a server bug this renderer would then follow. The create path has no other source, which is exactly why it must read the response. Pinned with an update response naming a different id.

recordId vs the reserved drawer param (ruling point 3)

Measured rather than assumed. recordId is already reserved in packages/app-shell/src/urlParams.ts as RECORD_DRAWER_PARAM, so no new registration was warranted — the name is registered, and adding a second constant for the same string would be two spellings of one contract. What was missing is that the registry described only one reader.

The two readers can never see one URL: React Router renders exactly one leaf per location; ObjectView (the only RECORD_DRAWER_PARAM call site outside the registry) is mounted under /apps/*; /forms/:name is a top-level route rendering DefaultHomeLayout + a form and no list. Disjoint subtrees. And the meaning is the same either way — "the record this surface is about". So the app-shell change here is prose only: the registry table now names both readers and the disjointness. The console spells the literal (app-shell exports only its package root, which does not re-export ./urlParams — the same unreachability createdRecordPath.ts documents), and a test pins the two spellings equal so they cannot drift in silence.

Tests, and the reverse verification

New: apps/console/src/components/FormPage.recordId.test.tsx (13 rendered cases) plus unit coverage for the new pure helpers in FormPage.test.ts.

With FormPage.tsx reverted to its post-#4279 state, the predicted directions were written down first and then measured — 11 red, 2 green, exactly as predicted:

PinReverted behaviour
renders the record's stored valuesRED — inputs empty (Received: blank, the defect verbatim)
PATCHes instead of insertingRED — one POST to the collection
lands back on the record it updatedRED — stays on /forms/...
URL id is the destination, not the response'sRED
404 / 403 ⇒ error stateRED — renders an empty CREATE form
object mismatch ⇒ refusesRED
blank ?recordId= ⇒ refusesRED
prefill_ overrides the record per field (x2)RED
control: no recordId ⇒ create path unchangedGREEN both sides
control: public /f/:slug ignores ?recordId=GREEN both sides

The two controls are green on purpose and are not change-detectors. The create-path control is the whole point of the card — this must not move create mode a byte. The public-path control guards a direction nothing in this change can turn red today: /f/:slug is anonymous and visitor-controlled, so if recordId ever leaked onto it that would be a data-exposure bug (an arbitrary-record reader and writer), which is worth a standing pin rather than a comment.

Verification

  • pnpm exec vitest run apps/console/38 files, 421 tests passed
  • pnpm exec vitest run packages/app-shell/344 files, 3286 passed (1 pre-existing skip)
  • type-check on @object-ui/console + @object-ui/app-shell (both tsc commands each) — clean, after building the dependency closure
  • downstream consumer sweep, prefix filter ...@object-ui/app-shell (i.e. the packages that consume it): 4 projects, all green
  • eslint on the changed files: 0 errors. Warning delta vs. the pre-fix baseline is +2 react-refresh/only-export-components, from the newly exported pure helpers — the same pattern this file already uses for every one of its exported helpers
  • changeset gates: check-changeset-presence.mjs and check-changeset-no-major.mjs both green (patch, both packages)

Out of scope

Filed #4292 while measuring this: ?recordId= carries no object name, so the id is resolved against the FormView's target object. When an action's target view edits a different object than the record it was fired from and ids collide across objects (per-table integer keys), the route silently reads and now writes the wrong record. The producer half lives in packages/core (ActionRunner), which this card's ruling put out of scope and which #4046 holds. Pre-#4278 the worst case was a duplicate insert; now it is a targeted update, which is why it is worth naming.


Generated by Claude Code

…4278)
`ActionRunner.executeForm` forwards the record an action was fired from as
`/forms/:name?recordId=<id>`, but the internal form route never read that
param. It consumed only the `prefill_` ones, so the route rendered EMPTY
inputs and its submit was an unconditional `POST /api/v1/data/:object` — an
insert. An "edit this record" action therefore opened a blank form and, on
Submit, created a second record while leaving the original untouched. In the
showcase: open a Task, click Log Time, submit, and a NEW Task appears.
`?recordId=` now selects the whole read/write pair: `GET /data/:object/:id`
to prefill, `PATCH /data/:object/:id` to save, landing back on the record it
updated. The verb is measured, not chosen — `PATCH /:object/:id ->
updateData` is the data plugin's declared route, it is what packages/rest
registers, and openapi-builtin-paths.ts records that the server "answers
PATCH and 405s the PUT". Every other update client in this workspace spells
the same pair.
A recordId the route cannot honour fails closed (ruling point 2): 403/404, a
payload whose object contradicts the form's target, and a present-but-blank
`?recordId=` each render the error state. None degrades to create mode —
a blank form whose submit inserts a duplicate is this bug's exact harm, so
silently falling back into it would re-arm the defect. A recordId belonging
to another object is not found under the form's own object and takes the
same refusal path.
Prefill precedence is per FIELD: explicit `prefill_` params win for the
fields they name, the stored record fills the rest, and a stored null or
empty string beats a create-time `defaultValue` so opening an edit form
never silently proposes a change the user did not make.
The destination after an edit is the id from the URL — the row we PATCHed —
not one read back off the response. `UpdateDataResponse` was measured and
declares the same `{ object, id, record }` triple as create, so
`readCreatedRecordId` would fit; it is deliberately not pointed at one,
because the edit path already holds that fact and a second reader could only
ever disagree by way of a server bug this renderer would then follow.
Create mode and the public `/f/:slug` path are untouched, both pinned as
controls: an anonymous visitor controls the URL, so honouring `recordId`
there would turn a public form into an arbitrary-record reader and writer.
In app-shell only the URL-param registry's prose changed, recording that
`recordId` has a second reader on a route that can never match the same URL
as the record drawer's (ruling point 3 — measured, not assumed; no new
constant, since the name is already reserved).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercelBot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 11, 2026 11:37am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)29.4 KB350 KB
Entry fileindex-DZukDeso.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)8.88KB3.25KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)8.27KB3.23KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)22.10KB4.37KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)35.76KB9.11KB
auth (createAuthenticatedFetch.js)4.37KB1.69KB
auth (index.js)2.35KB1.07KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)4.91KB0.87KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)488.62KB108.26KB
core (index.js)3.04KB1.15KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)150.04KB39.79KB
fields (index.js)228.45KB56.62KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)2.65KB1.06KB
i18n (pickLocalized.js)1.70KB0.83KB
i18n (provider.js)16.38KB5.47KB
i18n (useObjectLabel.js)27.59KB6.63KB
i18n (useSafeTranslation.js)4.52KB1.96KB
layout (index.js)38.98KB10.85KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)8.75KB3.06KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.71KB3.79KB
plugin-calendar (index.js)45.23KB12.45KB
plugin-charts (index.js)61.73KB17.54KB
plugin-chatbot (index.js)180.33KB42.79KB
plugin-dashboard (index.js)118.79KB30.79KB
plugin-designer (index.js)210.91KB42.67KB
plugin-detail (index.js)238.98KB59.76KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)114.58KB27.68KB
plugin-gantt (index.js)164.14KB39.98KB
plugin-grid (index.js)187.97KB49.90KB
plugin-kanban (index.js)48.60KB13.41KB
plugin-list (index.js)109.18KB26.48KB
plugin-map (index.js)17.00KB5.32KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)40.60KB10.58KB
plugin-timeline (index.js)26.21KB7.52KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.03KB20.55KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.67KB2.37KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)23.71KB7.96KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.23KB0.66KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)4.47KB2.03KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.05KB1.52KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

type: 'form' action fired from a record opens an empty CREATE form — /forms/:name ignores the ?recordId= ActionRunner forwards

2 participants

@yinlianghui@claude