fix(fields): one answer for a lookup reference that resolved to nothing (objectui#8695) - #9078
Conversation
…ng (objectui#8695) `LookupCellRenderer` split ONE epistemic state — a reference this screen did not resolve — by the SHAPE of the string. `isLikelyOpaqueId` sent opaque-looking ids to a muted `—` and everything else to confident bare text. Re-measured on this base with `reference_to: 'sys_user'`: `'Ada Lovelace'` rendered byte-identically to what a `text` cell prints for the same string, so the screen stated a confident fact it did not have and a dirty row read exactly like a clean one; `'01HQZX9K2M4N6P8R'` rendered a muted `—`, destroying the raw id that objectui#8434's triage named as the only clue for diagnosing an existing dirty row. Two opposite failures, one state. Both arms now render objectui#8434's remedy: the raw value kept visible beside a stated, epistemic marker. `master_detail`, `tree` and the multi-value chip shape route through the same answer. The sentence is a SIBLING i18n key, not the `user` one: that pack value ends "was not resolved to a user", false on a lookup pointing at any other object and pinned byte-for-byte by two existing tests. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB
✅ 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
|
In-seat position — 🛑 HELD IN DRAFT. Not a PASS, and
|
…kup-unresolvable-one-answer
Fork ruling — the
|
| 正常路由(列表→详情页) | 甘特右键「查看详情」浮层 | |
|---|---|---|
| 页脚创建人 | 创建人 Dev Admin · 47分钟前 ✅ |
创建人 g3WkZnvugj4DnYw8u5Mo6ig3ljDhiFGO ❌ |
and its located defect, verbatim: 该路径传入的 schema 无 created_by 字段定义 → type:'text' → String(value) 裸 ID 直出.
⇒ What objectui#2688 asks for is routing through the reference renderer. It does not ask for
the id to be hidden. The card also records that the id it complains about exists —
created_by = g3Wk… 在 sys_user 中存在,name = Dev Admin. In the scenario the card describes the id
disappears because it resolves, not because anything suppresses it.
2. The pin asserts on a state objectui#2688 never described
Its fixture has no dataSource and no options, so nothing can resolve. The pin therefore
asserts on the FALLBACK — and the fallback was isLikelyOpaqueId → muted —, i.e. exactly the arm
this PR removes. "The id is absent" was only ever a proxy for "this went through the reference
renderer", read off the placeholder that renderer happened to draw.
3. ⭐ Measured: the proxy could not tell the reference renderer from an empty cell
Three renders of the same footer, DOM captured:
| fixture | rendered | old assertion |
|---|---|---|
unresolved created_by, this PR |
<span data-slot="unresolved-reference" title="Unresolved reference: g3Wk… was not resolved to a record on this screen"><svg …circle-question-mark…/><span class="truncate">g3Wk…</span></span> |
❌ fails |
unresolved created_by, base |
muted — |
✅ passes |
no created_by at all |
actor omitted entirely — no span | ✅ passes |
EmptyValue for comparison |
<span data-slot="empty-value" aria-label="No value" …>—</span> |
— |
The base placeholder is glyph-identical to EmptyValue, and an absent actor satisfies the
assertion just as well as a present one. ⇒ the retired assertion passed for a blank cell.
4. The decisive measurement — what the new arm actually draws
This was the open question, and it is answered above: not a bare 32-char hash. It is an icon,
plus a stated sentence on title naming the value, plus the raw value truncated — hidden
without being deleted. Reading B's objection (that objectui#2688's user experience regresses to raw
ids) does not survive the DOM.
5. Blame cannot attribute the pin
git log -- packages/plugin-detail/src/__tests__/DetailView.title2688.test.tsx returns exactly one
commit, 2609812d2, a bulk baseline carrying hundreds of changesets. ⇒ there is no authoring PR
that argued for this assertion. Against it stands objectui#8434 — a closed, triaged, dispatched
card whose ruling is explicit: the affordance must be additive (absence is not a signal),
epistemic ("this screen did not resolve it", never "not found"), and must keep the raw value
visible because it "is the only clue for diagnosing existing dirty rows".
⇒ Ruling
A pin encoding an implementation artifact, corrected in place with the reasoning on the page.
Same shape already accepted twice in this lane: filter-dialect-equivalence-7221 (objectui#9195)
and the confirmVariant refusal pin (objectui#8978). ⛔ The assertions are re-derived, not
deleted — they now name what objectui#2688 actually asked: the footer must render the audit actor
through the reference renderer rather than degrading to a text cell. A docblock on the file records
the move and why.
Ablation — the re-derived pin still bites
Forcing UserRef's enrichedField.type to 'text' (objectui#2688's located defect, reproduced):
2 failed | 3 passed, and the two failures are exactly the footer assertions. Restored: 5 passed.
The new form is strictly stronger than the retired one, which passed for an empty cell.
Bump raised patch → minor
The lane's test is does existing stored data render differently — and here some of the affected
data is not broken. LookupCellRenderer auto-resolves only the first primitive of an array
(primaryPrimitiveId; resolveLabel returns a name only when val === primaryPrimitiveId) — a
documented cheapness policy, not a defect. So a multi-value lookup holding perfectly clean ids has
entries 2..n wearing the new marker where they previously printed a bare id or a dash. The
single-value name-shaped and opaque-shaped cases would each have been patch alone, since both were
already wrong; this one is a visible change on a working screen over correct data. @object-ui/i18n
is minor for the ordinary reason — two keys ADDED to ten packs.
origin/main merged in (the PR was behind). Contract-review record follows on the pushed head.
Generated by Claude Code
…asked for The two footer assertions in `DetailView.title2688.test.tsx` read `expect(queryByText(OPAQUE_ID)).toBeNull()` — "the raw id must not appear" — and this change makes an unresolved reference keep its raw value visible, so they were left deliberately red pending a ruling. Ruled: the pin encoded an implementation artifact, not objectui#2688's ask, and is corrected in place. objectui#2688 is a bug report. Its expected-correct column is `创建人 Dev Admin · 47分钟前` — the RESOLVED NAME — and it records that the id it complains about exists in `sys_user` with `name = Dev Admin`. The defect it locates is the degradation itself: `objectSchema.fields.created_by` absent ⇒ `type:'text'` ⇒ `String(value)`. In the scenario the card describes the id disappears because it RESOLVES, not because anything hides it. The pin's fixture has no dataSource and no options, so nothing can resolve; it therefore asserted on the fallback, which was `isLikelyOpaqueId` → muted `—`, the arm this change removes. "The id is absent" was only ever a proxy for "this went through the reference renderer" — and a weak one. Measured on this fixture: that placeholder is glyph-identical to `EmptyValue`'s `—`, and a footer rendered with no `created_by` at all omits the actor entirely; both satisfy the retired assertion. It could not tell the reference renderer from a blank cell. The assertions now name what the card asked for, directly: the footer must render the audit actor through the reference renderer rather than degrading to a text cell, observable as `data-slot="unresolved-reference"` carrying a sentence that names the value. Ablation, forcing `UserRef`'s enriched type to `'text'` (objectui#2688's located defect reproduced): 2 failed | 3 passed, the two failures exactly these assertions; restored, 5 passed. The new form is strictly stronger than the one it replaces, which passed for an empty cell. Bump raised patch → minor. `LookupCellRenderer` auto-resolves only the first primitive of an array (`primaryPrimitiveId`), a documented cheapness policy and not a defect, so a multi-value lookup holding perfectly clean ids has entries 2..n wearing the new marker where they previously printed a bare id or a dash — a visible change on a working screen over correct data. The changeset's key count is corrected too: this adds one key, `detail.unresolvedLookupReference`, across ten packs; `detail.unresolvedReference` already shipped with objectui#8434. `origin/main` merged in — the branch was behind. Ref: objectui#8695 Ref: objectui#8434 Ref: objectui#2688 Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ Co-authored-by: Claude <noreply@anthropic.com>
Contract review — head
|
| check | result |
|---|---|
vitest run packages/plugin-detail/ packages/fields/ packages/i18n/ |
396 files / 5457 tests passed |
check:i18n-keys |
exit 0 — 3005 en keys, 2731 call sites, every in-scope key resolves |
check:i18n-drift |
exit 0 — 1 key added, 0 en values changed |
check:i18n-dead-keys |
exit 0 |
check-changeset-claims --base b775500af |
exit 0, 5 findings — answered below |
ablation (UserRef enriched type forced to 'text') |
2 failed / 3 passed, exactly the two footer rows; restored 5 passed |
⭐ check:i18n-drift earned its keep: it caught a claim I had written one minute earlier in the changeset — "two keys are ADDED to ten packs". Measured, this adds one, detail.unresolvedLookupReference; detail.unresolvedReference shipped with objectui#8434. Corrected in the same commit. Born-false, by me, in the paragraph explaining the bump.
The five claims check-changeset-claims asked me to re-read
The gate judges name resolution, never meaning; the reading is mine. All five name packages/fields/src/index.tsx, which this PR edits.
| changeset | claim | reading at this head |
|---|---|---|
6694-dashboard-lookup-reference-meta |
reference_to_field has zero member reads anywhere; titleFormat never read off a field meta |
✅ holds — 0 member reads; all 4 titleFormat occurrences in this file are comments |
6837-reference-to-arm-deletion |
three readers left alone, reading FieldMetadata |
✅ untouched — field-prop reads unchanged base→head (reference_to 1→1, reference 1→1, options 1→1) |
6661-app-launcher-nav-menu-renderers |
three new strings under console.nav in en.ts + nine packs |
✅ untouched — this PR adds a detail.* key, not a console.nav one |
7166-retire-inert-fieldmeta-copies |
zero occurrences of the three retired keys in this file, against a control of 22 | descriptionField/lookupColumns/lookupFilters = 0 on base and head). The control cardinal has drifted: 22 → 24 on main → 26 at this head. ⛔ Not rewritten — see below |
6625-retire-fieldmeta-decimals |
zero .decimals member reads across fields, i18n, components, core and plugin-dashboard itself |
efdc6c62, so past-tense, but worth an owner's eye |
⛔ Neither drifted cardinal was rewritten, deliberately. Both sentences anchor their measurement to a named commit ("Re-measured on this branch's base (efdc6c62)"), which makes them recorded control readings, not present-tense claims about today's tree — a control's value at measurement time does not rot. And the .decimals one drifted with no help from this branch. Reported to the seats that own them rather than edited by the seat passing through.
Why the gate is cleared
The one thing that was actually unresolved — the fork between the #2688 footer pin and objectui#8434's ruling — is ruled in the comment above, on measurement rather than on preference, and the pin is re-derived, not deleted, with a docblock on the file recording the move. The two assertions that were red on purpose are green on their own terms and still bite: the ablation reddens exactly them.
Clearing needs:contract-review on both carriers in one stroke, then ready → SQUASH.
Generated by Claude Code
|
| Step | Outcome |
|---|---|
| Build packages | success |
| Check console performance budget | failure |
Reason: The byte budgets passed, but the locale-catalogue composition half returned no trustworthy VERDICT: the eager-closure report could not be read, a catalogue was emitted under no chunk of its own, or the resident catalogue is not in the eager closure at all. The step log says which. This is not a passing budget — and it is not a size regression either.
See the workflow run for details.
📦 Bundle Size Report
| Package | Size | Gzipped |
|---|---|---|
| app-shell (consoleActionDispatch.js) | 0.20KB | 0.19KB |
| app-shell (index.js) | 16.69KB | 6.21KB |
| app-shell (runtime-config.js) | 20.68KB | 7.36KB |
| app-shell (types.js) | 0.01KB | 0.04KB |
| app-shell (urlParams.js) | 10.06KB | 3.86KB |
| auth (ActiveOrganizationStorage.js) | 25.05KB | 9.16KB |
| auth (AuthContext.js) | 0.31KB | 0.24KB |
| auth (AuthGuard.js) | 2.07KB | 1.00KB |
| auth (AuthProvider.js) | 40.18KB | 10.59KB |
| auth (AuthShell.js) | 3.49KB | 1.40KB |
| auth (ForgotPasswordForm.js) | 12.21KB | 3.45KB |
| auth (LoginForm.js) | 18.15KB | 5.39KB |
| auth (PreviewBanner.js) | 0.90KB | 0.50KB |
| auth (RegisterForm.js) | 6.65KB | 2.22KB |
| auth (SocialSignInButtons.js) | 9.61KB | 3.89KB |
| auth (UserMenu.js) | 3.41KB | 1.23KB |
| auth (auth-gate-events.js) | 1.29KB | 0.66KB |
| auth (authStyles.js) | 5.04KB | 1.72KB |
| auth (createAuthClient.js) | 40.21KB | 10.80KB |
| auth (createAuthenticatedFetch.js) | 8.46KB | 3.43KB |
| auth (index.js) | 3.19KB | 1.44KB |
| auth (invitation-status.js) | 1.22KB | 0.70KB |
| auth (org-roles.js) | 6.66KB | 2.78KB |
| auth (phone-identifier.js) | 1.11KB | 0.66KB |
| auth (types.js) | 0.59KB | 0.35KB |
| auth (useAuth.js) | 5.30KB | 1.02KB |
| auth (useWorkspaceAdminStatus.js) | 11.08KB | 4.58KB |
| collaboration (CommentThread.js) | 26.08KB | 7.56KB |
| collaboration (LiveCursors.js) | 3.17KB | 1.27KB |
| collaboration (PresenceAvatars.js) | 6.49KB | 2.64KB |
| collaboration (PresenceProvider.js) | 2.79KB | 1.13KB |
| collaboration (index.js) | 1.68KB | 0.73KB |
| collaboration (useCollaborationTranslation.js) | 6.05KB | 2.52KB |
| collaboration (useCommentSearch.js) | 1.98KB | 0.88KB |
| collaboration (useConflictResolution.js) | 7.75KB | 1.86KB |
| collaboration (useMentionNotifications.js) | 1.81KB | 0.68KB |
| collaboration (usePresence.js) | 6.33KB | 1.84KB |
| collaboration (useRealtimeSubscription.js) | 7.91KB | 2.01KB |
| components (index.js) | 500.84KB | 114.81KB |
| core (index.js) | 8.52KB | 3.41KB |
| create-plugin (index.js) | 27.94KB | 9.51KB |
| data-objectstack (index.js) | 209.38KB | 57.92KB |
| fields (index.js) | 247.88KB | 62.48KB |
| i18n (LocalizationContext.js) | 1.76KB | 0.96KB |
| i18n (builtinAggregateLabels.js) | 0.86KB | 0.49KB |
| i18n (currency.js) | 1.22KB | 0.64KB |
| i18n (fallbackInterpolation.js) | 6.25KB | 2.77KB |
| i18n (i18n.js) | 8.87KB | 3.64KB |
| i18n (index.js) | 5.21KB | 2.26KB |
| i18n (pickLocalized.js) | 7.62KB | 3.26KB |
| i18n (provider.js) | 32.15KB | 10.49KB |
| i18n (useDisplayLocale.js) | 2.85KB | 1.45KB |
| i18n (useObjectLabel.js) | 34.34KB | 9.17KB |
| i18n (useSafeTranslation.js) | 5.60KB | 2.33KB |
| layout (index.js) | 38.84KB | 10.95KB |
| mobile (MobileProvider.js) | 0.92KB | 0.49KB |
| mobile (ResponsiveContainer.js) | 0.94KB | 0.38KB |
| mobile (breakpoints.js) | 1.51KB | 0.70KB |
| mobile (createOfflineDataSource.js) | 5.61KB | 1.75KB |
| mobile (index.js) | 1.99KB | 0.87KB |
| mobile (offlineQueue.js) | 3.91KB | 1.35KB |
| mobile (pwa.js) | 0.97KB | 0.49KB |
| mobile (serviceWorker.js) | 1.48KB | 0.62KB |
| mobile (serviceWorkerSource.js) | 3.41KB | 1.48KB |
| mobile (useBreakpoint.js) | 1.54KB | 0.65KB |
| mobile (useGesture.js) | 6.96KB | 1.98KB |
| mobile (useOfflineSync.js) | 1.99KB | 0.72KB |
| mobile (usePullToRefresh.js) | 2.53KB | 0.85KB |
| mobile (useResponsive.js) | 0.72KB | 0.42KB |
| mobile (useSpecGesture.js) | 4.39KB | 1.66KB |
| mobile (useTouchTarget.js) | 1.01KB | 0.54KB |
| permissions (MePermissionsProvider.js) | 13.52KB | 4.88KB |
| permissions (PermissionContext.js) | 0.31KB | 0.25KB |
| permissions (PermissionGuard.js) | 0.89KB | 0.45KB |
| permissions (PermissionProvider.js) | 6.24KB | 2.16KB |
| permissions (discardProofCache.js) | 1.04KB | 0.55KB |
| permissions (evaluator.js) | 8.39KB | 3.10KB |
| permissions (index.js) | 0.93KB | 0.41KB |
| permissions (store.js) | 0.91KB | 0.42KB |
| permissions (useFieldPermissions.js) | 1.28KB | 0.53KB |
| permissions (usePermissions.js) | 4.83KB | 2.27KB |
| plugin-ai (index.js) | 14.81KB | 3.63KB |
| plugin-calendar (index.js) | 49.03KB | 13.93KB |
| plugin-charts (index.js) | 71.52KB | 19.98KB |
| plugin-chatbot (index.js) | 195.32KB | 46.51KB |
| plugin-dashboard (index.js) | 131.27KB | 34.62KB |
| plugin-designer (index.js) | 215.68KB | 44.27KB |
| plugin-detail (index.js) | 253.02KB | 65.74KB |
| plugin-editor (index.js) | 2.23KB | 1.05KB |
| plugin-form (index.js) | 136.79KB | 34.19KB |
| plugin-gantt (index.js) | 166.94KB | 41.04KB |
| plugin-grid (index.js) | 211.56KB | 57.50KB |
| plugin-kanban (index.js) | 46.10KB | 14.33KB |
| plugin-list (index.js) | 112.58KB | 27.65KB |
| plugin-map (index.js) | 20.49KB | 6.83KB |
| plugin-markdown (index.js) | 13.88KB | 4.80KB |
| plugin-report (index.js) | 43.42KB | 11.92KB |
| plugin-timeline (index.js) | 30.10KB | 8.74KB |
| plugin-tree (index.js) | 9.54KB | 3.31KB |
| plugin-view (index.js) | 84.42KB | 20.80KB |
| providers (DataSourceProvider.js) | 0.75KB | 0.39KB |
| providers (MetadataProvider.js) | 1.37KB | 0.59KB |
| providers (ThemeProvider.js) | 1.90KB | 0.85KB |
| providers (UploadProvider.js) | 11.66KB | 3.50KB |
| providers (index.js) | 0.45KB | 0.23KB |
| providers (types.js) | 0.01KB | 0.04KB |
| react-runtime (index.js) | 5.62KB | 2.34KB |
| react (LazyPluginLoader.js) | 4.47KB | 1.63KB |
| react (SchemaRenderer.js) | 94.03KB | 31.02KB |
| react (data-invalidation.js) | 5.05KB | 2.08KB |
| react (index.js) | 4.63KB | 2.18KB |
| react (schema-input.js) | 4.25KB | 2.04KB |
| react (spec-input.js) | 0.20KB | 0.18KB |
| sdui-parser (codegen.js) | 6.58KB | 2.74KB |
| sdui-parser (dashboard-widget-options.js) | 3.08KB | 1.30KB |
| sdui-parser (index.js) | 5.66KB | 2.50KB |
| sdui-parser (input-type.js) | 2.84KB | 1.40KB |
| sdui-parser (kanban-quick-add.js) | 3.89KB | 1.87KB |
| sdui-parser (parse.js) | 25.28KB | 7.80KB |
| sdui-parser (provenance.js) | 3.66KB | 1.82KB |
| sdui-parser (types.js) | 0.28KB | 0.23KB |
| sdui-parser (validate.js) | 14.82KB | 4.99KB |
| types (ai.js) | 0.20KB | 0.17KB |
| types (api-types.js) | 0.20KB | 0.18KB |
| types (app.js) | 2.87KB | 1.00KB |
| types (base.js) | 0.20KB | 0.18KB |
| types (blocks.js) | 0.20KB | 0.18KB |
| types (complex.js) | 2.93KB | 1.49KB |
| types (crud.js) | 0.20KB | 0.18KB |
| types (dashboard-filter-alias.js) | 6.23KB | 2.74KB |
| types (data-display.js) | 3.75KB | 1.85KB |
| types (data-protocol.js) | 0.20KB | 0.19KB |
| types (data.js) | 0.20KB | 0.18KB |
| types (designer.js) | 1.85KB | 0.85KB |
| types (disclosure.js) | 0.20KB | 0.18KB |
| types (error-code.js) | 1.54KB | 0.88KB |
| types (expression.js) | 0.20KB | 0.18KB |
| types (feedback.js) | 0.20KB | 0.18KB |
| types (field-types.js) | 0.20KB | 0.18KB |
| types (form.js) | 0.20KB | 0.18KB |
| types (http-inflight.js) | 8.87KB | 3.73KB |
| types (http-retry.js) | 4.32KB | 2.02KB |
| types (icon-key-migration.js) | 4.26KB | 1.63KB |
| types (index.js) | 4.74KB | 2.25KB |
| types (layout.js) | 0.20KB | 0.18KB |
| types (managed-by.js) | 0.19KB | 0.18KB |
| types (mobile.js) | 4.73KB | 2.28KB |
| types (navigation.js) | 0.20KB | 0.18KB |
| types (objectql.js) | 0.20KB | 0.18KB |
| types (overlay.js) | 0.20KB | 0.18KB |
| types (permissions.js) | 0.20KB | 0.18KB |
| types (plugin-scope.js) | 0.20KB | 0.18KB |
| types (record-components.js) | 0.20KB | 0.19KB |
| types (record-semantics.js) | 1.28KB | 0.67KB |
| types (registry.js) | 0.20KB | 0.18KB |
| types (reports.js) | 0.20KB | 0.18KB |
| types (select-option.js) | 0.20KB | 0.19KB |
| types (spec-report.js) | 5.05KB | 1.93KB |
| types (spec-ui-namespace.js) | 0.20KB | 0.19KB |
| types (strict-authoring-face.js) | 14.27KB | 5.47KB |
| types (system-fields.js) | 3.33KB | 1.54KB |
| types (theme.js) | 6.28KB | 2.87KB |
| types (ui-action.js) | 8.11KB | 3.32KB |
| types (views.js) | 0.20KB | 0.18KB |
| types (widget.js) | 0.20KB | 0.18KB |
Size Limits
- ✅ Core packages should be < 50KB gzipped
- ✅ Component packages should be < 100KB gzipped
⚠️ Plugin packages should be < 150KB gzipped
CI: 35 of 36 green. The one red is a gate defect, not this PR's — root-caused, and the fix is open as objectui#9228Head ⛔ Not called a flake. It reproduces, and it is caused by this diff — but the defect is in the gate
The job's failing step is
The step then runs
⭐ All three were sitting in
|
Correction to my contract review above — the
|
…kup-unresolvable-one-answer
✅ 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 review — head
|
Part of #8695 — ⛔ deliberately not a closing keyword. The remaining half is a ruling, named below, and this PR must not silently close a card that is waiting for one.
🛑 The stop-and-report fork FIRED. This PR stays in draft.
The card raises the fork against itself:
lookuphas "its own already-shipped affordance and its own trade-off", and if objectui#8434's remedy would break that affordance, the mother reasoning is branch-specific and the card returns for its own ruling.It does, and the break is not the em-dash going away — that was always what a transplant means. It is that a second, independently-shipped ruling depends on the em-dash and points the other way:
Same renderer, same population (an unexpanded
sys_userid), opposite requirements. The muted—is what satisfies objectui#2688. The affordance is what satisfies objectui#8434.usernever had this second ruling on it, which is exactly why the mother card landed with every dependent green and this one cannot.The whole dependent surface is green except those two assertions — 661 test files, 6449 tests, 1 file failed / 2 tests failed, and they are precisely objectui#2688's:
⛔ Those two are left failing on purpose. Editing them would be deciding the fork by deleting the evidence for one side of it. They are not a pin on this card's subject (the confident-name arm) — they are a pin on the affordance the fork is about.
⛔ No third behaviour was invented. The obvious "split the difference" — keep
—in the audit footer, show the raw id in grids — is a third answer to the same epistemic state, decided by surface instead of by string shape. That is the defect one level up, which is what the card and the claim both fence off.The ruling this needs
RecordMetaFooter.tsxstates objectui#2688's defect in the very terms this change re-creates: "Without a fallback the field degrades totype: 'text'and the footer prints the raw user id (objectui#2688)." Two honest readings of that card, and they ship different products:Reading A — objectui#2688's pin is incidental; this PR is right and the pin is a pin to UPDATE. The card's reported defect (原文, 未译) is
创建人 g3WkZnvugj4DnYw8u5Mo6ig3ljDhiFGOwhere创建人 Dev Adminbelonged, and its located cause is that the footer bypassed the reference renderer (type:'text'→String(value)). Its remedy was to route it through the renderer. This PR does not un-route it; it changes what that renderer draws when nothing resolves. In the real product — with a data source — the footer still resolves toDev Adminin both worlds, so objectui#2688's actual complaint is untouched. And the new rendering is further from that complaint than—was: the reported harm was an opaque hash presented as if it were the creator's name, which a stated "this screen did not resolve it" makes impossible.Reading B — objectui#2688's pin is its ruling, and the
—is load-bearing. The pin's docblock says "never the raw opaque id" twice, in normative voice, and someone wrote it deliberately. An audit footer showing a 32-char hash to every user on every record is a real, shipped regression that no amount of marker glyph makes acceptable, and it is precisely the "already-shipped behaviour that people may rely on" the card warned about. Under this reading objectui#8434's remedy is not transplantable tolookupas-is, and objectui#8695 needs its own remedy — which is a ruling, not an implementation detail.My recommendation, offered as input to the ruling and not acted on: Reading A, on the grounds that objectui#2688's own body locates its defect at bypassing the renderer, and that the
—never satisfied objectui#2688 either (—is notDev Admin) — it merely hid the failure the card was really about. If Reading A is taken, this PR is complete and the only remaining edit is the two-line pin update inDetailView.title2688.test.tsx, with an updated docblock recording that objectui#8695 moved it.Everything below is the work as it stands, so that either ruling is cheap to execute.
Step one: both readings re-taken on today's tree
The card measured at
da5e4f69e;packages/fields/src/index.tsxwas released by objectui#9068 after that. Re-derived by symbol ona2eb62fd0and re-measured before anything was changed.getCellRenderer('lookup'),reference_to: 'sys_user', no data source, all three in one render:'Ada Lovelace'span class="block max-w-full truncate" title="Ada Lovelace"→Ada Lovelace'wo_1'span class="block max-w-full truncate" title="wo_1"→wo_1'01HQZX9K2M4N6P8R'span class="block max-w-full truncate text-muted-foreground" title="—"→—Both readings reproduce verbatim, and worse than the card states: the first two are byte-identical to what a
textcell prints for the same string — objectui#8434's grading sentence, one renderer over.master_detailandtreerender identically. The disagreement between the shapes is the finding; a one-sided reading could not tell "the renderer is confident" from "this value happens to resolve", which is why every reading here is two-sided and carries its own resolved control.ZONE 2 — the PM's assumptions, each falsified or held
A. "unresolvable is one state" — falsified in the card's own direction, and it is a load-bearing finding.
It is one state at this seam and six distinct causes behind it, and the renderer distinguishes none:
dataSource, or noreference_to);state: 'err');primaryPrimitiveId), so entries 2..n never reach the resolver.Measured: causes 1, 3 and 4 render byte-identically, and cause 2 renders identically to them on first paint.
useLookupNamereturnsstring | undefined, so thepending/err/okdiscriminator its own cache stores is dropped before any caller sees it. ⇒ the card's premise that this renderer "can genuinely distinguish 'fetched and absent' from 'never fetched'" is false as the code stands — and that premise was the one asymmetry offered as a reason the mother ruling might not transfer. Even surfacing the discriminator could not upgrade the sentence: (3) and (4) also cover a record the viewer may not read, and "cannot read" vs "does not exist" is an existence-oracle boundary this lane does not cross (objectui#8631). ⛔ No distinction the data does not carry was added.B. "objectui#8434's remedy is transplantable" — held for the presentation, falsified for the sentence, and falsified at the level the fork is about.
The drawing transplants unchanged (same
data-slot="unresolved-reference", same glyph, same muted treatment, raw value kept). Two adaptations were needed, and one of them is the fork:detail.unresolvedReferenceends "was not resolved to a user", false on a lookup pointing at any other object, and pinned byte-for-byte by two existing tests, so it could not be widened in place. This adds a sibling key,detail.unresolvedLookupReference, in all ten locale packs. Each call site keeps its literal key rather than taking one as a prop — a key read from a constant is downgraded to report-only bycheck:i18n-keys, which PR fix(fields): auserreference that resolved to nothing says so (objectui#8434) #8698 documents and this preserves.—before. It is louder now, and that is stated rather than hidden.C. "
LookupCellRendereris the only renderer with this split" — held. Nothing filed, nothing widened.isLikelyOpaqueIdhas exactly one call site in the repo (this one), so no sibling renderer decides presentation from string shape.SelectCellRendereris the nearest neighbour — an unmatched value falls tohumanizeLabel(String(val))— but that is one answer, not a shape-decided pair, and its option set is the whole oracle. Not this defect.D. "the two readings still reproduce" — held, re-measured above on
a2eb62fd0, after objectui#9068.E. "no existing test pins the current confident-name rendering" — FALSIFIED, and it turned out to be two different pins with two different dispositions.
packages/fields/src/__tests__/cell-truncation.test.tsx→ "LookupCellRenderer: primitive non-opaque value" assertedexpectTruncating(...)on the confident-name arm. So objectui#3466 pinned the confident bare-text rendering of an unresolved reference — that reading was deliberate once. This card is chartered to change exactly that, so the case is UPDATED, not routed around: it now asserts what 记录详情页字段值超长时尾部无声消失:span.truncate 容器缺 min-width:0,ellipsis 永不触发,且无 title 兜底全文 #3466 actually contracted for and what still holds — a single-line value must not expand its column and its full text must stay reachable. The shape that meets it moved (truncateon the text span inside aninline-flexwrapper;overflow: hiddengives a flex item an automatic minimum size of zero, so it shrinks instead of pushing the row wider) and the full value is exposed through the wrapper's statedtitle.packages/plugin-detail/src/__tests__/DetailView.title2688.test.tsxpins the—, on a second surface, in normative voice, from a different card. That is the fork's fence, not this card's subject ⇒ left failing, untouched.F. "presentational only" — held. Cell renderers are consumed only inside React render trees (
ObjectGrid,RelatedList,DetailSection,HeaderHighlight,RecordMetaFooter,ObjectKanban, …); no caller reads a renderer's output back into data, and the grid's export path does not go throughgetCellRendererat all. Nothing here touches a query, a sort, an export, the wire, or the save path —reference_not_foundis not in the diff.The change
resolveLabel's last two arms become one:The unresolved arm renders the raw value beside a stated marker, inside
ReferencedRecordLinkso the reference stays navigable (objectui#4336) — the value is the foreign key, so an unresolved reference is still worth being able to open. The multi-value chip keeps its muted background and gains the affordance inside it; the+Noverflow chip'stitlenow lists the values it hides instead of a row of dashes. Also collapsed: the—for an unresolved value andEmptyValue's—for no value at all were the same glyph for two different states, so a user could not tell them apart either.isLikelyOpaqueIdstays exported — published surface, and retiring it is a breaking change no display card is entitled to make — but nothing calls it. Its docblock now says why re-introducing a presentation caller would re-open this card.Verification
Assertions are on rendered output, and the load-bearing ones assert agreement, not per-shape strings: each shape's markup is normalised by its own raw value and the three must collapse to one string. A per-shape assertion would go green on a repair that made both shapes wrong in a new matching way; the agreement assertion plus a floor (the affordance is present, the raw value survives, the sentence is epistemic) is what closes that.
packages/fields: 158 files / 2750 tests passed,VERDICT command-exit 0.packages/i18n+plugin-detail+plugin-grid+plugin-dashboard+plugin-list+plugin-kanban+plugin-gantt+plugin-tree: 661 files / 6449 tests — 1 file failed, 2 tests failed,VERDICT command-exit 1. Both failures are objectui#2688's, quoted at the top; nothing else moved.check:i18n-keys(it judges the new call site — "every in-scope call-site key resolves against the en pack (3005 keys) … every call site passes exactly the arguments that value has holes for"),check:i18n-drift(1 key added, 0 en values changed — theusersentence did not move),check:i18n-dead-keys,check:control-bytes(7270 files) plus a manual control-byte scan of every changed file,check:icon-record-names,check:new-line-citations,check:unreferenced-sources,check-changeset-presence(12 source files of 2 released packages, 1 changeset),check-governed-queue-guard --test(NOT GOVERNED).type-check— NOT MEASURED,PREREQUISITE NOT MET:tsc --noEmitin both packages exits 2 withTS2307 Cannot find module '@object-ui/types' | '@object-ui/core' | '@object-ui/components'across files this diff never touches — the workspace dependency closure is not built in this container. A missing prerequisite, not a red gate. ⛔ Not read as green either. Declared to CI.Ablation — read site mutated, seven legs, all from the committed fix
Predictions were written down before running (
PREDICTIONS.mdin the run scratch). Every leg mutates the read site inLookupCellRenderer/ its affordance, never a pin, and each leg proves the mutation on disk (git hash-object≠ the HEAD blobaa93359c…, plus an injected-marker line count 0 → N) and proves the restore by state (blob back to the HEAD blob andgit diff HEADempty). Per-test classification from vitest's JSON reporter. Population: 28 tests (the new 22-test pin +cell-truncation's 6).'—', else raw, both confidentif (true) return { …, unresolved: true }options, resolver)unresolved: falseReference not found: … does not existdetail.unresolvedReference+ theusersentenceEmptyValueEmptyValuecontrolBUGis the leg that matters — the only one that reproduces the shipped defect. It kills the agreement assertion and, on the death stringthe card's grading sentence, observed failing.
⭐
NONEis the leg that proves the floor is load-bearing. Under it all three shapes still agree — they are identically wrong — so the equality half of the agreement pin passes; the test dies on its floor (data-slot="unresolved-reference"absent). That is exactly the failure mode a per-shape assertion could not catch and the reason the floor is there.All 22 assertions in the new pin were observed failing on at least one leg. The four in the population that never failed are
cell-truncation's cases forTextCellRenderer,UserCellRenderer,FileCellRendererandSelectCellRenderer— other renderers, correctly insensitive to aLookupCellRenderermutation.Acceptance notes
Observed, not filed (none is a reproducible defect, a declared-contract breach, or an authoring trap):
isLikelyOpaqueIdis now an exported helper with no caller in this repo. Dead-ish code behind a published export; retiring it is a breaking change and belongs to whoever next revisits@object-ui/fields' published surface, not to a display card. Successor: none currently queued.useLookupNamecollapsespending/err/okintostring | undefinedat the hook boundary while its cache holds the discriminator. That is what makes cause (2) above indistinguishable from (4). Not a defect — nothing today is entitled to say more than the epistemic sentence — but a future "loading" affordance would start there. Successor: none currently queued.—for an unresolved value andEmptyValue's—for no value were the same glyph for two different states. Folded into this change rather than filed, since the arm was being rewritten anyway.🤖 Generated with Claude Code
https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB
Generated by Claude Code
Generated by Claude Code