Skip to content

fix(fields): read a 0 geolocation coordinate as a place, not as an absence (objectui#8055) - #8898

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-8055-geolocation-zero-coordinate
Sep 9, 2026
Merged

os-zhuang merged 1 commit into
mainfrom
claude/issue-8055-geolocation-zero-coordinate

Conversation

@claude

@claude claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Fixes #8055

GeolocationField's display/read path carried two independent defects. Both are fixed
here; the card was right that fixing either alone leaves the other live, and the ablation
below proves the two apart by making each leg red on a different test.

The class sweep — 23 conditional sites, not the card's 5

The card's "Affected sites" list is a snapshot of one measurement, so the file was swept
against the class criterion instead: every "has a value" test whose operand may
legitimately be 0 must use nullish semantics, and no JSX conditional may carry a numeric
operand.
All 23 conditional/coercion sites in the file, with verdicts:

Class hits — fixed (5). The operand is a coordinate or an accuracy, all of which are
legitimately 0, and three of them are JSX render expressions:

site before why it is in the class
formatLocation if (!loc.latitude || !loc.longitude) return '' falsy test on the parsed NUMBER
openInMaps if (!location.latitude || !location.longitude) return same; refuses to open a valid place
readonly "View on map" {location.latitude && location.longitude && (…)} falsy test and numeric JSX operand
editable "View on map" {location.latitude && location.longitude && (…)} same shape
accuracy row {location.accuracy && (…)} same shape, on a value that is not exotic

Already correct — deliberately untouched (2). value={location.latitude ?? ''} and its
longitude twin value={location.longitude ?? ''}. Both use ??, both already showed "0".
The card named the first; the second is its twin and is equally correct. Neither was "fixed"
into consistency with the broken guards — they are the live controls, see below.

Not in the class (16), each for a stated reason. value || {} (the operand is the
composite object, never legitimately 0); fieldValue ? Number(fieldValue) : null (the
operand is the raw input string, and '0' is truthy — this is objectui#6848's pinned
emission site and stays as it is); if (!navigator.geolocation) (an API object); the map URL
template (an interpolation, not a test); if (readonly) and the isLoading ternary
(booleans); {formatted ? … : …} (a ternary on a string, whose '' is the intended
sentinel — a ternary always renders exactly one branch, so no operand can leak);
disabled={readonly || …} ×3 and className={… ? … : …} ×2 (attribute values, not children,
and never numeric); aria-invalid={!!error || !!…} ×2 (explicitly coerced); and the two new
predicates themselves.

The fix

One predicate that is nullish and boolean-valued, because the two defects need both
properties and neither implies the other:

  • nullish so 0 counts as present — that is defect 1. A boolean-valued falsy guard would
    still call the equator "no location".
  • boolean so && can never hand React a number to render as a text node — that is
    defect 2. A nullish-but-numeric guard would still leak the 0.

The delta is exactly 0 and -0. The predicate excludes NaN, as the old falsy guard
did. A bare n != null — the card's wording taken literally — would have started rendering
"NaN, NaN" at a surface that has always shown the placeholder for an unreadable coordinate:
a second display defect wearing the first one's fix. Infinity, null, undefined, a
missing half of the pair and a non-number all answer exactly as they did before. Pinned as a
control.

Before / after

The new pins run against main's version of the file reproduce the card's reading verbatim:

AssertionError: expected '—0' to contain '0.000000, 120.155100'
Expected: "0.000000, 120.155100"
Received: "—0"

Against main: 10 failed | 5 passed (15). The 5 that pass are exactly the 5 controls —
a control that goes red with the subject is not a control. After the fix: 15 passed (15).

Ablation — two legs, expectations written before running

Each leg mutated the committed tree, proved the mutation was on disk (marker counts plus a
git hash-object comparison against the HEAD blob, with an empty hash treated as failure),
ran the pins, then restored and proved the restore by state — blob back to the HEAD value
and git diff HEAD empty — never by an exit code. Both legs restore under a
trap … EXIT INT TERM using git checkout HEAD -- ABSOLUTE_PATH. No build step is on the
path: the pins import the widget by a same-package relative source specifier, so dist/
never participates.

leg mutation predicted observed
1 — defect 1 presence predicate body back to !!n (still boolean, so defect 2 stays fixed) display returns to the empty placeholder; no stray 0 7 failed / 8 passed, exactly as predicted
2 — defect 2 readonly guard back to {location.latitude && location.longitude && (…)} (presence still nullish) coordinates still render; the stray 0 returns 3 failed / 12 passed, exactly as predicted

The legs are red on different tests, which is the point:

  • Red only under leg 1: the four "renders the coordinates / renders the accuracy row"
    tests. Under leg 2 they stay green, because formatLocation is still nullish.
  • Red only under leg 2: readonly: the coordinates row has no bare text-node children,
    which failed with expected [ '0' ] to deeply equal [] — the card's stray 0, caught as
    the DOM shape it actually is. Under leg 1 it stays green, because a boolean guard renders
    nothing either way.
  • Red under both: the two readonly "View on map" affordance tests. That is honest rather
    than sloppy — that single site carried both defects, so it is the user-facing consequence
    of either, and it is deliberately not the separator.

Defect 2's oracle is written as a DOM shape, not as a string diff: it reads the non-blank
text nodes that are direct children of the element holding the guard, which is exactly
where React puts a rendered numeric operand. It is deliberately not recursive — the accuracy
row legitimately renders "Accuracy: ±", "0", "m" as three nodes one level down.

Per the card's warning about assertions a 0 can fool: the positive half asserts
toFixed(6)'s own product ("0.000000, 120.155100"), which a stray 0 cannot satisfy;
not.toContain(EM_DASH) is never used alone.

The two things the card did not measure

Does the sibling LocationField carry the same shape? Measured: no — and ⛔ it was not
touched.
Its presence test is isFiniteNumber (typeof n === 'number' && Number.isFinite(n)), which admits 0; its readonly render is
{coordinateText(value) || EmptyValue}, whose operand is a string ("0, 0" for a stored
zero, truthy), so no numeric operand exists to leak; its one other guard's operand is
string | null. It already answers this card's class criterion, by a documented ruling —
objectui#6272 option A1, whose docblock reasons about 0 explicitly — and a landed pin
already asserts the opposite of this defect: LocationField.specShape.test.tsx, "carries a
zero coordinate as the real place it is, not as a default"
, asserting textContent is
'0, 0'. Re-run green. No follow-up card is warranted.

Does any consumer depend on the current empty rendering? Measured: no. No test, snapshot
or doc paragraph pins "a 0 geolocation coordinate displays as empty". Every zero-coordinate
fixture in the tree belongs to LocationField/ObjectMap and pins the opposite. The one
cross-package renderer of this widget is plugin-detail's InlineFieldInput; its emptiness
authority tests use the non-zero { latitude: 30.2741, longitude: 120.1551 } fixture and are
unaffected. Both suites re-run green (see below). No pin was changed, and none needed to be.

Verification

run result
pnpm --filter '@object-ui/fields^...' run build (dependency closure) exit 0
pnpm --filter @object-ui/fields run type-check (tsc --noEmit and tsconfig.test.json, so the new pins are typechecked) exit 0
pnpm --filter @object-ui/fields run test 154 files, 2638 tests, all passed
pnpm --filter @object-ui/plugin-detail run test (the consumer) 155 files, 1415 tests, all passed
pnpm --filter @object-ui/fields run lint 0 errors (987 pre-existing warnings; the changed files contribute none — the one warning on the widget is the pre-existing unused field param)
new pins, packages/fields/src/__tests__/GeolocationZeroCoordinate-8055.test.tsx 15 passed

Measured at 3db5b0ee2. Every heavy run went through the container's shared verify lock;
each is quoted from the wrapper's own VERDICT command-exit line, never a bare $?.

A first type-check returned exit 2 — PREREQUISITE NOT MET (workspace unbuilt,
Cannot find module '@object-ui/components'). That is NOT MEASURED, not red; it is recorded
here only so the green above is not mistaken for a re-run of nothing. It was re-run after the
closure build.

Published surface: byte-identical. packages/fields/dist/index.d.ts was built from
origin/main's version of the widget and from this branch's and diffed: identical. The
two predicates and the narrowing type are module-private (no export), so export * in
index.tsx does not re-export them.

Line-citation census (objectui#8875). This change inserts ~59 lines near the top of
GeolocationField.tsx, moving every line below. scripts/cross-file-line-citation-census.mjs
reports zero citations naming that file, in either direction, and a five-syntax grep
confirms nothing in the tree addresses it by line. The two new files add no path:line
citation to the population. No class-(A) repair was needed, and nothing class-(B) was
touched.
Re-checked against the final commit.

验收备注

  • The EmptyValue placeholder path is unchanged and still correct for a genuinely absent
    coordinate, for a half pair (one coordinate present, one absent — a half pair must not
    invent the other half) and for NaN. All three are pinned as controls.
  • openInMaps was fixed as part of the class sweep, not as a separate ask: it is the same
    falsy presence test one line from formatLocation, and it refused the same valid value a
    second time. Pinned by asserting the URL window.open receives.
  • No out-of-scope findings were filed, and none are being withheld. The two open questions
    the card left were both measured and both came back negative (above); neither produces a
    follow-up card. Successor for the LocationField half: none — nothing to carry.
  • ⛔ Not touched, deliberately: LocationField, the emission path (handleFieldChange, which
    objectui#6848 measured and pinned as already correct), and the two ?? '' input bindings.

Generated by Claude Code

…absence (objectui#8055)

`GeolocationField`'s display path carried two independent defects, and fixing
either one alone left the other live.

1. Presence was tested by asking the parsed NUMBER whether it was falsy
   (`if (!loc.latitude || !loc.longitude)`), and `0` is falsy. The equator
   (`latitude: 0`), the prime meridian (`longitude: 0`) and a perfect
   `accuracy: 0` all took the "no value" branch: the `EmptyValue` em dash
   instead of the coordinates, no accuracy row, and no "View on map" — whose
   `openInMaps` handler refused the same value a second time.

2. Three of those guards were JSX render expressions
   (`{location.latitude && location.longitude && (…)}`). With `latitude === 0`
   that evaluates to `0`, and React renders the NUMBER as a text node. Measured
   on `main`, `{ latitude: 0, longitude: 120.1551 }` rendered `"—0"`: the dash
   from defect 1 and a stray `0` from defect 2.

Both close on one predicate that is nullish AND boolean-valued — nullish so `0`
counts as present, boolean so no numeric operand can reach the DOM through
`&&`. The five sites were found by sweeping the file against the class
criterion, not by taking the card's list: every "has a value" test whose
operand may legitimately be `0`, and every JSX conditional with a numeric
operand.

The delta is exactly `0` and `-0`. `NaN` stays excluded, as the old falsy guard
had it — a bare `!= null` would have begun rendering `"NaN, NaN"` at a surface
that has always shown the placeholder for an unreadable coordinate.
`value={location.latitude ?? ''}` was already correct and is untouched; it is
pinned below as a control, along with the byte-identical non-zero render.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01611D6ZaRaMmwTNQmSbk8MH
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3487.4 KB 3512.7 KB
Main entry chunk (gzip) 144.1 KB 350 KB
Entry file index-U2QDoaCV.js —
Status PASS —

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

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.23KB 114.67KB
core (index.js) 7.48KB 2.96KB
create-plugin (index.js) 26.68KB 8.94KB
data-objectstack (index.js) 200.01KB 55.77KB
fields (index.js) 246.97KB 62.30KB
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) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
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.94KB
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.39KB 19.92KB
plugin-chatbot (index.js) 194.54KB 46.34KB
plugin-dashboard (index.js) 131.71KB 34.50KB
plugin-designer (index.js) 215.51KB 44.29KB
plugin-detail (index.js) 252.45KB 65.33KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.26KB 34.13KB
plugin-gantt (index.js) 166.96KB 40.93KB
plugin-grid (index.js) 210.86KB 57.28KB
plugin-kanban (index.js) 57.53KB 16.46KB
plugin-list (index.js) 112.54KB 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.55KB 3.32KB
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) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
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.55KB 2.45KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 13.64KB 4.59KB
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

@os-zhuang
os-zhuang marked this pull request as ready for review September 9, 2026 21:16
@os-zhuang
os-zhuang added this pull request to the merge queue Sep 9, 2026
Merged via the queue into main with commit f52a9d7 Sep 9, 2026
35 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-8055-geolocation-zero-coordinate branch September 9, 2026 21:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(fields): GeolocationField renders a valid 0 coordinate as EMPTY, and leaks a literal 0 into the DOM

2 participants