Skip to content

fix(plugin-grid): the link column renders a real anchor when the host publishes record URLs (#4490) - #4531

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-4490-linkcell-real-anchor
Aug 13, 2026
Merged

fix(plugin-grid): the link column renders a real anchor when the host publishes record URLs (#4490)#4531
yinlianghui merged 2 commits into
mainfrom
claude/issue-4490-linkcell-real-anchor

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Closes #4490

What was wrong

The list's link: true column — and the auto-linked primary field — rendered as
a span role="link" with no href, navigating only through
navigation.handleClick(row). So the surface users actually open records from
had none of a link's native affordances: no middle-click / Cmd-click
open-in-new-tab, no "copy link address", no hover status-bar URL. And
role="link" without an href is a weaker contract for assistive tech than a
real anchor.

It was also the odd one out. PR #4489 had just given record-detail and
related-list lookup VALUES real anchors, which left the list column as the
weakest of the three surfaces — the reverse of the situation #4336 opened with.

The seam (the measured question)

RelatedRecordActionsProvider mounted only on the record DETAIL body, via
RelatedRecordActionsBridge inside RecordDetailView. That is exactly why
#4489's anchors reached lookup values there and never reached the list page.

Two candidate seams were measured:

Seam Cost
Mount the existing provider on the list host (app-shell ObjectView) No new package dependency — app-shell and plugin-grid both already depend on @object-ui/react, where the context and its recordHref (added by #4489) live. No new schema key, no new context member.
Thread an href callback through the grid's navigation context Needs a new ObjectGridSchema member in @object-ui/types plus threading through useNavigationOverlay — a third package and a new public schema surface, for the same result.

The first was taken: provider-mounting + context consumption, exactly the shape
the ruling allowed. packages/react's context file is untouched#4489
already added recordHref / openRecord, so no new optional member was
required.

The URL is not built in the grid. ObjectView publishes its own builder,
and that builder is the SAME expression its "open in new window" navigation
action has always used (strip a trailing /view/:viewId, append
/record/:recordId, derived from the live pathname). Both now call one
extracted listRecordDetailUrl, so the anchor a user middle-clicks and the tab
the row's Cmd-click opens address the same record by construction rather than by
coincidence. That mirrors what #4489 did in the bridge, where onView and
recordHref were collapsed onto one builder.

Two deliberate limits on what the list host publishes:

  • recordHref addresses THIS view's object only. The builder is derived
    from the current list's pathname, so it cannot name a record of another
    object; any other object returns null, which consumers render as the plain
    value. A lookup cell pointing at a third object therefore renders exactly as
    it does today. Inventing a URL for it here is the console: copied invitation link is unusable — buildAcceptUrl rebuilds the trailing-dot host that resolveHomeUrl already retired #4472 mistake — the
    console-wide, any-object builder stays on the detail page's bridge, which has
    the routable-object set this page does not.
  • resolve returns no handlers. A list page has no related lists. The
    context reads an omitted handler as "capability unavailable", i.e. the same
    read-only outcome a consumer gets with no provider at all, so mounting this
    provider grants nothing that was not there before. The record drawer mounts
    RecordDetailView, whose bridge wraps the whole detail body — that nearer
    provider keeps owning the detail surface, unchanged.

The change

LinkCell consumes recordHref when the host publishes one and renders a real
anchor, with #4489's click split:

  • plain left click: preventDefault() + the existing navigation.handleClick(row),
    so the SPA path (drawer / modal / page, whatever the view configured) is
    completely unchanged;
  • modifier and non-primary clicks: not prevented — the browser does what it
    does with any link;
  • stopPropagation() on every click, including modifier clicks: without it a
    Cmd-click would ALSO reach the row handler, which opens its own tab, for two
    tabs from one click;
  • Enter is the anchor's native activation (the browser fires a click, which the
    handler above turns into the SPA path). Space is the one affordance a link
    does not do natively, so it is carried over from the span explicitly.

Accessible name is the display text, unchanged.

Graceful degradation, pinned

No host recordHref (Studio designer, embedded renderers, standalone grids), a
host that cannot route to this object, or a row with no id — all render exactly
today's span role="link". Pinned as the full markup string, not merely "no
anchor", so a stray class or attribute cannot slip in unnoticed.

Red-first (measured, not predicted)

The fix was committed, then ObjectGrid.tsx and ObjectView.tsx were reverted
to origin/main with the new tests kept. 16 red / 7 green.

Grid file, 5 red:

× renders a real anchor at the host-built record URL
    AssertionError: expected null not to be null      (the span, no anchor)
× the auto-linked primary field gets the same anchor
    expect(received).toHaveAttribute()                (received null)
× plain left click is prevented and takes the existing SPA path, exactly once
    Unable to fire a "click" event - please provide a DOM element
× Cmd/Ctrl click is NOT prevented and does not run the SPA path
    Unable to fire a "click" event - please provide a DOM element
× middle click is NOT prevented and does not run the SPA path
    Unable to fire a "click" event - please provide a DOM element

Reported honestly rather than dressed up: the three click cases fail by not
finding the anchor
, not by observing wrong click behavior. Against
origin/main, "no anchor" and "an anchor that mishandles modifier clicks" are
indistinguishable, so those three discriminate in the FORWARD direction only —
they pin the split as LinkCell is edited from here on. The case that
discriminates old from new is the first one.

Host file, 11 red — all TypeError: listRecordDetailUrl is not a function /
listRecordActionsValue is not a function. Also stated plainly in the file:
these are pure functions that do not exist on origin/main, so the module dies
at import before an assertion runs. They pin the builder's contract (object
scoping, encoding, the /view/ strip, call-time pathname read) rather than
proving a behavior change.

Green in BOTH worlds (the controls, 7): the whole must-not-change block —
the byte-identical span, plain click still taking the SPA path with no
double-fire, non-link columns untouched, a lookup column pointing at another
object staying plain, a row with no id getting no anchor — plus the two "host
publishes no href" cases.

Verification

Grading

Patch on both packages. Measured both ways with dist/ and *.tsbuildinfo
cleared between builds: each package's entry dist/index.d.ts — the only
path in either exports map — is byte-identical. The full emitted tree
differs in exactly one module-local file, app-shell/dist/views/ObjectView.d.ts,
which gains the two helper declarations; it is not reachable from the entry
(grep finds 0 occurrences there) and views/index.ts re-exports only
ObjectView. Module-local additive, so patch — not minor, never major.


Generated by Claude Code

claude added 2 commits August 13, 2026 04:16
… publishes record URLs (#4490)

The list `link: true` column was a `span role="link"` with no href, so the
surface users open records from had none of a link's native affordances and a
weaker a11y contract than the real anchors PR #4489 gave detail/related lookup
values.

`LinkCell` now consumes the host-published `recordHref` on
`RelatedRecordActionsContext` and renders a real `<a href>` with #4489's click
split; `ObjectView` (the console list host) mounts the existing provider and
publishes the SAME record-URL builder its "open in new window" action already
used. No host URL, no anchor — today's span, unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
…re the changeset (#4490)

The reverse verification was run after committing the fix: revert
ObjectGrid.tsx and ObjectView.tsx to origin/main, keep the new tests. Measured
16 red / 7 green — one more grid case than predicted, and the three click cases
fail by not finding the anchor rather than by observing wrong click behaviour.
The docblock now records what was measured instead of what was predicted.

Both packages take a patch: each entry dist/index.d.ts is byte-identical
(measured with dist/ and tsbuildinfo cleared between builds); only the
module-local app-shell/dist/views/ObjectView.d.ts gains the two helper
declarations, and it is not reachable from the entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercel Bot commented Aug 13, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 13, 2026 5:09am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 24.7 KB 350 KB
Entry file index-BxdKHsw3.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 9.56KB 3.59KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 8.92KB 3.41KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 25.13KB 5.40KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 38.46KB 10.17KB
auth (createAuthenticatedFetch.js) 6.34KB 2.43KB
auth (index.js) 2.35KB 1.07KB
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.02KB 0.88KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 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.65KB 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) 489.32KB 108.45KB
core (index.js) 3.37KB 1.34KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 156.23KB 42.29KB
fields (index.js) 230.14KB 57.12KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 3.35KB 1.38KB
i18n (pickLocalized.js) 3.69KB 1.73KB
i18n (provider.js) 23.12KB 7.62KB
i18n (useDisplayLocale.js) 2.84KB 1.45KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 38.98KB 10.85KB
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.74KB
mobile (index.js) 1.50KB 0.62KB
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.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.86KB 12.91KB
plugin-charts (index.js) 62.07KB 17.65KB
plugin-chatbot (index.js) 181.21KB 43.14KB
plugin-dashboard (index.js) 120.95KB 31.53KB
plugin-designer (index.js) 212.58KB 42.83KB
plugin-detail (index.js) 239.88KB 59.99KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.58KB 27.68KB
plugin-gantt (index.js) 164.14KB 39.98KB
plugin-grid (index.js) 189.28KB 50.29KB
plugin-kanban (index.js) 48.62KB 13.42KB
plugin-list (index.js) 111.13KB 27.12KB
plugin-map (index.js) 18.16KB 5.81KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 41.16KB 10.96KB
plugin-timeline (index.js) 26.25KB 7.53KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.08KB 20.55KB
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.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 23.73KB 7.96KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.23KB 0.66KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.05KB 1.52KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
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 (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
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

Copy link
Copy Markdown
Collaborator Author

PM step-7 复核 — ACCEPT (session_017Qqyix2QcnpUC9XeYVDzx3)

  • Seam measured before chosen: the bridge mounts in exactly one place today (RecordDetailView:2157), which is precisely why fix(detail): lookup field values link to the referenced record (#4336) #4489's anchors never reached the list. Candidate A (mount the existing provider on ObjectView) wins on measurement — no new cross-package edge, no new context member, and the record URL is EXTRACTED from the host's own new_window branch rather than hand-built, so the one-resolver doctrine holds: both the action and the published recordHref now call listRecordDetailUrl.
  • The two deliberate limits are the right shape and both pinned: pathname-derived builder answers null for any other object (the console-wide builder stays on the detail bridge, which has routableObjects), and the frozen empty handler set is the context's documented capability-unavailable. Drawer shadowing verified — the nearer detail bridge still owns that surface.
  • Red-first with honest bounds, which is worth naming as exemplary: the report states plainly that the three click-behaviour cases discriminate forward-only (on origin/main they fail by not finding an anchor), the host-file reds are module-death rather than behaviour observation, and the genuinely discriminating case is the anchor-at-host-URL one. Claims sized to evidence.
  • Degradation pinned byte-identical (span role="link" full outerHTML), both-worlds pins for null-href / no-recordHref / other-object lookup / missing id; fix(plugin-grid): the cross-page select-all banner works under external pagination (#4464) #4503 + fix(plugin-grid): select-all-matching replays the host's real query — or abstains — instead of fanning out unfiltered (#4501) #4510 pins re-run by name and green inside the full 64-file plugin-grid run; row-click path untouched.
  • The measured non-gate on nav-suppressed views is accepted: handleClick's modifier branch runs BEFORE the 'none' check today, so a Cmd-click already opens a tab there — gating the href would have made the anchor inconsistent with the row's own behaviour. The flagged scope widening (self-referencing lookup cells newly link under the list host) is fix(detail): lookup field values link to the referenced record (#4336) #4489's mechanism applied consistently, measured and pinned.
  • .d.ts: entry surface byte-identical in both packages; the two new functions live in a module-local d.ts not reachable from either entry (grep-verified) → patch/patch per the fix(app-shell): organization & invitation UI translates its six English holdouts (#4474) #4496 precedent line. Correct.
  • CI 20/20 green on per-job conclusions; the self-caught false-convergence (403 body parsed as zero unfinished checks) was discarded before any report relied on it — good recovery, and the aggregate-vs-jobs lesson stands.

Auto-merge armed (squash).


Generated by Claude Code


Generated by Claude Code

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

Projects

None yet

2 participants