Repository navigation
fix(core): harvest a row action's defaultFromRow seed field and {field} target tokens into $select - #10386
Conversation
…d} target tokens into $select
`listViewPredicates` decides which fields a projected row must carry for
ListView, ObjectGrid and RelatedList's authored path. It read an action's
visible / disabled predicates and its recordIdField, but not the row key a
`defaultFromRow` param seeds from (`field ?? name`, the precedence
resolveActionParam reads) or the `{field}` tokens the console's api handler
fills from the row. On a projected row those keys were absent: the param
dialog opened blank and the URL got an empty segment, without a word.
Both are now harvested, bare identifiers only, and still pass each
consumer's declared-field and FLS gates.
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C
… positive control An assertion that only reads an absence passes on a harvest that never runs. Each negative case now carries a sibling that must be harvested, so every new pin can fail against the tree before the fix. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C
…romRow caveat is narrowed in the same release
The pending plugin-detail changeset warns that a `defaultFromRow` param or a
`{field}` target token only gets its field when the projection already carries
it. The harvest now names both, so the paragraph would publish a gap the same
release closes. Appended a supersession note rather than rewriting another
seat's text; the declared package set is unchanged.
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C
…olumn-identity ratchet `defaultFromRowKey` spells `field ?? name`, which the #3104 dual-read scanner counts. It is the row-key half of resolveActionParams' two-layer pair, mirrored so the projection asks for the key the runtime reads; `columnIdentity()` would also read `fieldName` and drop an empty string, so it is not the same key. Listed with that verdict, total 11 -> 12. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C
…of `any` Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Contract reviewServed-tier: Rendered by an isolated review subagent spawned by the ① Derived judgments
② Semver levelAppropriate: ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #10277
Clause-②: no
What was wrong
listViewPredicatesin@object-ui/core(packages/core/src/utils/predicate-fields.ts) is the harvestListView,ObjectGridandRelatedList's authored path use to decide which fields a projected row must carry. It read an action'svisible/disabled, itsrecordIdFieldand the object'suserActionsvisibleWhen/disabledWhen, but not two other row keys a row action reads:defaultFromRowparam seeds from;{field}tokens of the action'starget.On a projected row those keys are absent.
resolveActionParamseeds a param only when the row owns the key, so the param dialog opened blank; the consoleapihandler filled the token with an empty string. Nothing said so.Measured first (triage rule 1), on
origin/main=ba0b61a60Temporary probes, deleted before commit. Fixture: object
team_memberwith fieldsname,team_id,user_id; view columns['name']; one row action shaped like objectstacksys_team_member.remove_team_member(paramsteamId/userIdbound tofield: 'team_id'/'user_id',defaultFromRow: true); a data source that returns only the keys$selectasks for.$selectObjectGrid, real kebab click, captured_rowRecordfed to the realresolveActionParams["id","name"]{"id":"tm_1","name":"Ada"}teamId: null,userId: nullname, team_id, user_id["id","name","team_id","user_id"]team_42,user_7ListView(child grid stubbed)["id","name"]id,nameonlyapiHandler, target/api/v1/teams/{team_id}/members/{user_id}, the projected row/api/v1/teams//members//api/v1/teams/team_42/members/user_7The premise holds. After the fix the same probes read: grid
$select["id","name","team_id","user_id"]andresolveActionParamsdefaultsteam_42/user_7with columns['name'];ListView, whose source is untouched, sends$select["id","name","team_id","user_id"]and hands the grid rows carrying both keys.The change
listViewPredicatesnow also pushes, for each def on the row, bulk and object action lists:record.KEYfor every param whosedefaultFromRowis set, where KEY isfield ?? nameand a bare identifier (therecordIdFieldgate);record.TOKENfor every{TOKEN}in a stringtarget, using the console handler's own pattern.Both flow through the existing
collectPredicateFieldRefsand then each consumer's existing gates. No consumer source changes.Mechanism assumptions, measured
A1, the param-to-field key. The runtime reads
row[field ?? name](rowValueKeyinapp-shell'sresolveActionParams) under an own-property check. The installed spec (@objectstack/spec17.4.0,ActionParamSchema) documents the key as "the resolved field name",namedefaulting tofield. The harvest reads exactlyfield ?? name, with the same truthiness test ondefaultFromRowthe seeding makes.carryOverneeds no arm of its own: the spec refuses it withoutdefaultFromRow: true, and its value is that same seed.A2, the token grammar. The console
apihandler (useConsoleActionRuntime) substitutes the pattern\{([a-z_][a-z0-9_]*)\}(flagsgi) from the row record;RecordDetailViewcarries the same pattern for record pages. The harvest uses it verbatim, so it harvests exactly what the handler fills. Skipped, and pinned as skipped:{owner.name}(dotted path),{a + b}(expression),${param.token}/${ctx.recordId}(the runner's owninterpolateTargetscopes, which read the param bag and the runner context, not the row),{1st}(not an identifier). The harvest is not narrowed by actiontype, for the asymmetry the harvest already documents: an extra column costs bytes, a missing one breaks silently.A3, FLS. Each consumer filters AFTER the harvest, on the harvested names:
ObjectGrid:collectPredicateFieldRefs(listViewPredicates(...)), then.filter(isProjectableField), then.filter(passesProjectionGate), which asksperms.checkField(objectName, f, 'read')for a declared field.ListView: each harvested name goes throughaddPredicateFieldandaddSpeculative, whose known-field gate is followed byperms.checkField(schema.objectName, f, 'read')for a declared, non-platform field.RelatedList: each operand must be declared (either container shape) or a platform column, and a declared one must passreadable(field), which asksperms.checkField(relatedObjectName, field, 'read').Pinned with the real
PermissionProvider:user_id, named by adefaultFromRowparam AND atargettoken and denied by policy, is in noRelatedListrequest (last$selectis['id','name','team_id']); the control with the denial lifted asks for it.A4, serial constraint.
ListView.tsxis untouched. Its projection changes through the shared harvest alone; that was read by the temporary probe above and is not pinned in this PR.A5, pins. Below.
Pins, and the ablation that shows each can fail
New:
packages/core/src/utils/__tests__/predicate-fields.test.ts: 8 cases in a newdescribe. The named producer's shape harveststeam_id/user_id, notteamId;namefallback; only seeding params; a non-identifier param key dropped;targettokens harvested; non-identifier tokens skipped; all three action lists; a value-bagparamsand malformed entries read nothing. Each negative case carries a same-fixture positive control, so it cannot pass on a harvest that never ran.packages/plugin-grid/src/__tests__/defaultFromRowProjection-10277.test.tsx: 5 cases. Object actions;rowActionDefsandbulkActionDefs;targettokens; PIN 4, through a real kebab click on a data source that honours$select, the row handed to the param-collection handler OWNSteam_id/user_id(the own-property checkresolveActionParammakes) with their stored values; the undeclared-key guard.packages/plugin-detail/src/__tests__/RelatedList.defaultFromRowSelect-10277.test.tsx: the FLS pin and its control (A3).The 19 pre-existing cases in
predicate-fields.test.tsare the unchanged-behaviour pins; they stay as written and green.Ablation, commit-first:
predicate-fields.tschecked out fromba0b61a60and hash-verified equal to the base blob, the three files run, then restored fromHEADwith the hash equal to the HEAD blob andgit diff HEADempty, all under an exit trap. Ablated: 15 failed, 19 passed (34), which is every new case red and every pre-existing case green. On HEAD: 34 passed (34). Last run on the final head756592af8.Two edits worth naming
packages/core/src/utils/__tests__/column-identity.ratchet.test.ts(inside the claimed test directory). The 列身份双读家族:field ?? name 两种优先序并存于 15+ 处——ingestion 归一 + 单键消费 + 禁新增闸门(objectstack#4115) #3104 dual-read scanner counts the newfield ?? nameread, and the whole core run went red on it. It is the row-key half ofresolveActionParams' two-layer pair, so it is recorded with verdicttwo-layerand a reason, total 11 to 12.columnIdentity()was not used: it also readsfieldNameand treats''as absent, so it would harvest keys the runtime never reads..changeset/10186-related-list-fls-select.md, OUTSIDE the claimed file surface. That pending changeset (@object-ui/plugin-detail: patch) ends by saying adefaultFromRowparam or a{field}URL token only gets its field when the projection already carries it. This PR makes that false in the same release, and a pending changeset publishes verbatim, so a supersession note is appended (the objectui#9542 precedent) rather than a rewrite; the declared package set is unchanged.check-changeset-overwritereports it (report-only; its case 2, a prose correction). It is its own commit,d877573da, so it can be dropped alone if the seat wants another route.Local verification
pnpm exec vitest run packages/core/plus the harvest consumer pins (defaultFromRowProjection-10277,recordIdFieldProjection,projectionFls-6898,gridNonAuthorKeys,RelatedList.defaultFromRowSelect-10277,RelatedList.selectFls-10186) at756592af8:Test Files 170 passed (170),Tests 3496 passed (3496).packages/plugin-grid/whole package atb77a55bc1:Test Files 151 passed (151),Tests 1453 passed (1453). The only later commit changes types in the plugin-detail pin.plugin-list: the 13 suites that read$select/listViewPredicates/rowActionDefs, atb77a55bc1: 13 files, 270 tests passed.plugin-detail: the 33RelatedList*suites atb77a55bc1: 33 files, 176 tests passed; the changed pin re-run at756592af8: 2 passed.tsc --noEmit && tsc -p tsconfig.test.json) of@object-ui/core,@object-ui/plugin-grid,@object-ui/plugin-detail: green;--listFilesshows each test program compiles the new or edited test file.756592af8:check-changeset-presence(1 changeset declared),check-changeset-no-major,check-changeset-fixed,check-changeset-claims(no pending changeset names a touched file; self-contradiction reading clean),check-pending-changeset-literals,check:new-line-citations(0 new citation(s)),check:control-bytes,check-vi-mock-specifiers,check-vi-mock-inherit,check-vi-mock-override-shape,check-test-path-roots, andcheck-changeset-overwritewith the report-only finding above.eslint --no-inline-config --format jsonover the 5 touched.ts/.tsxfiles at756592af8: 5 files, 0 errors, 0 warnings. Population: the rooteslint.config.jsblockfiles: ['**/*.{ts,tsx}'], and all 5 appear in the JSON output (none ignored). Invariance: the config sets noparserOptions.project/projectService, so there is no type-aware linting, and no rule undereslint-rules/reads another file; this diff cannot move a verdict on an untouched file. The repo-widepnpm lintis CI's.plugin-listandplugin-detail, and every other package; CI runs them.Acceptance notes
TARGET_ROW_TOKENcopies the consoleapihandler's pattern anddefaultFromRowKeycopiesrowValueKey's precedence. Both cite their source by symbol; nothing re-derives the parity, which is said here rather than left implied (AGENTS.md 完善设计器的每一个细节 #9). Hoisting the pattern and the row-key reader into core, whereapp-shelland the harvest could share one spelling, would close it.undoableoperation: 'update'row action recordsnullas the prior value of a written field the projection left out, so Undo would clear that field. Measured with a temporary probe; the fix has two readings (harvest the written keys, or refuse Undo when the row lacks one), so it needs its own card.Session:
https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C(dispatched by thedomain:ui#4seat).Generated by Claude Code