Skip to content

fix(fields): GeolocationField emits null for a cleared coordinate - #8056

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-6848-geolocation-clear-emits-null
Sep 6, 2026
Merged

fix(fields): GeolocationField emits null for a cleared coordinate#8056
os-steve merged 1 commit into
mainfrom
claude/issue-6848-geolocation-clear-emits-null

Conversation

@os-steve

@os-steve os-steve commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Fixes #6848

Step 1 was a MEASUREMENT, and it re-prices the card

Triage wrote the escalation condition into the card so it would not be re-argued: if an absent key and an explicit null are treated differently on the write path, this is immediately a priority:p1 silent data-loss defect (the user clears a coordinate, sees an empty box, saves, and the old coordinate survives, with no diagnostic); if they are treated identically, it drops to a p3 consistency tidy.

The answer has two halves, and they point in opposite directions. My verdict: NOT p1 — recommend priority:p3.

Half 1 — absent really is NOT null on the write path (the condition's literal antecedent is TRUE)

Read on the tree, entrance re-taken (triage cited :2958; main has moved, it is now packages/data-objectstack/src/index.ts:3081):

  • ObjectStackAdapter.update() passes data straight through to this.client.data.update(...) — no normalization of any kind.
  • @objectstack/client 17.2.0 data.update sends method: "PATCH", body: JSON.stringify(data).
  • driver-memory's update() merges { ...table[index], ...data, ... } — a shallow spread.
  • driver-sql's update() issues builder.update(payload)SET for the keys present in the payload.
  • The platform states the same contract in prose, in packages/objectql/src/secret-fields.ts: "To clear the stored X, write null; to leave it unchanged, omit the field."

Executed end to end through that model: an absent key leaves {"id":"r1","amount":42} unchanged, an explicit null writes {"id":"r1","amount":null}. Different.

Half 2 — but the harm the p1 grade is DEFINED by is not reachable for this widget

The escalation condition defines its own harm in the parenthetical: the old coordinate survives the save. It does not.

The dropped key is nested one level below the key the write path merges on. GeolocationField emits a composite, so the payload is { FIELD: { longitude: 120.1551 } } — the composite's own key is present. A location value is stored as a single JSON column (JSON_COLUMN_TYPES in driver-sql, which lists composite/address/location/record as objects), and nothing on the path deep-merges (the only deepMerge in objectql is the metadata registry's, not the data write path). So the whole value is replaced.

Measured through the real widget, then through the real chain (sanitizeFormData → the client's JSON.stringify → the drivers' shallow merge):

GEO emitted keys      : ["latitude","longitude"]
GEO emitted latitude  : undefined       ('latitude' in obj -> true)
GEO wire body         : {"geo":{"longitude":120.1551}}
GEO stored after save : {"longitude":120.1551}
GEO latitude survives?: false            <-- the clear DOES persist

sanitizeFormData was confirmed to be a pass-through (out[key] = value) that only walks top-level keys, and ObjectForm sends the whole sanitized formData on edit with no dirty-diff and no merge.

No silent data loss exists here, and none is fixed by this PR. The antecedent is true in general; the consequent is not reachable for this widget. I am reporting both rather than collapsing them, because the two halves disagree and picking a side quietly is what the card was written to prevent.

The falsy-guard question, measured as asked

fieldValue ? … : undefined tests the raw input string, not the parsed number. '0' is a non-empty string, so a legitimate 0 coordinate takes the value branch:

GEO zero emitted lat  : 0 | typeof number
GEO zero wire         : {"geo":{"latitude":0,"longitude":120.1551}}

The trap is NOT live at this emission site. It is now pinned, so a future "simplify" cannot move the guard onto the parsed number, where it would be live.

What changed

packages/fields/src/widgets/GeolocationField.tsx (emission site re-taken at :82, verbatim as cited):

-      [fieldName]: fieldValue ? Number(fieldValue) : undefined,
+      [fieldName]: fieldValue ? Number(fieldValue) : null,

CurrencyField, PercentField and NumberField already emit null for the identical action, and LocationField emits null too (onChange(null)) — this composite was the only widget of the class that did not. undefined cannot survive serialization: JSON.stringify drops the key outright, so the emission stopped saying "the user cleared this" the moment it left memory.

GeolocationValue widens latitude / longitude / accuracy to number | null. undefined stays admissible — an untouched coordinate is genuinely absent.

Tests

  • New pin packages/fields/src/__tests__/GeolocationClearEmission.test.tsx (8 tests). It asserts the emission is null and that it survives JSON.stringify — the second half is the point, since the defect was never about which sentinel sat in memory. Also pins the 0 coordinate and the class-wide agreement across all four widgets.
  • Retuned pin NumberInputWidgets.browserDeliverable.test.tsx — its geolocation / 1e row pinned the defect as current product behaviour. It now reads null, with the history kept in the comment rather than deleted, because that reading is what the divergence was reported from.
  • pnpm --filter @object-ui/fields test135 files passed, 2194 tests passed.
  • pnpm --filter @object-ui/fields type-check → exit 0.
  • Gates run locally, all exit 0: check:control-bytes, check:unreferenced-sources, check:published-dist, check:published-tsconfig-exclude, and the three changeset gates (check-changeset-presence confirms 1 changeset for 3 changed published-source files, compared against merge-base 06761b351).

Reverse verification (ablation)

Predicted direction before running: turns red. Fix committed first, mutation proven to land on disk by blob hash (33979f46826a7f) and by anchor counts (: null 1→0, : undefined 0→1), with an EXIT INT TERM trap holding the restore:

VERDICT ablation-exit=1
Test Files  2 failed (2)
Tests  5 failed | 28 passed (33)

Five red across both pin files. Restore proven by byte identity — on-disk blob back to 33979f42…, matching HEAD, with git diff HEAD empty. The tests import the widget by relative source path, so no dist is involved and the mutation reaches them directly.

Cross-package type check

fields rebuilt first, and the emitted packages/fields/dist/widgets/GeolocationField.d.ts confirmed to carry latitude?: number | null — so the reading below is of a fresh build, not a cache. Then a two-leg probe through a real consumer (@object-ui/plugin-form):

  • latitude: null assigned to GeolocationValueexit 0 (rejected before this change, so the widening is live in the consumer's view).
  • latitude: 'not-a-number'exit 1, error TS2322 — the control leg, proving the check has teeth.

GeolocationValue is referenced by name nowhere outside packages/fields, and the coordinate keys have no other consumer (the latitudeField / longitudeField hits in @object-ui/types are map field names, a different thing).

Declared narrowing

I ran the owning package's full suite, the targeted gate families, and the consumer-direction type sweep. I did not run every one of the 20 affected packages' test suites locally — that is CI's run, and this is a declared narrowing rather than a silent one. Gate status at report time is therefore in_progress; CI convergence is the PM's read, not mine.

Note for whoever derives gates here: scripts/pm/dispatch-gates.mjs does not exist in this repo, so the families above were derived by hand from .github/workflows/ and package.json.

Scope fences honoured

Out-of-scope finding, filed not fixed

#8055GeolocationField renders a valid 0 coordinate as EMPTY, and leaks a literal 0 into the DOM. Measured while working here: readonly at { latitude: 0, longitude: 120.1551 } (a real point on the equator) renders "—0" against a control of "30.274100, 120.155100View on map". Two distinct defects — the falsy-on-number guards in formatLocation / openInMaps / the map-button gates, and 0 && x rendering a literal 0 through JSX. That is the widget's display path, a different code path and a different defect class from this card's emission path, so per the dispatch's instruction it is named rather than silently folded in.


Generated by Claude Code

Emptying a latitude or longitude box emitted `{ …, latitude: undefined }`
where CurrencyField, PercentField and NumberField all emit `null` for the
identical user action, and LocationField emits `null` too. This composite
was the only widget of the class that did not.

`undefined` cannot survive serialization: JSON.stringify drops an
undefined-valued key outright, so the emission stopped saying "the user
cleared this" the moment it left memory. The pin asserts the emission is
`null` AND that it survives a JSON round-trip — the assertion the old code
actually failed.

Measured, and deliberately NOT claimed: the escalation on the card was that
the dropped key reaches a PATCH as an ABSENT key ("leave it alone") so the
old coordinate silently survives a save. Absent really is not null on the
write path — the client sends PATCH + JSON.stringify, driver-memory merges
{ ...stored, ...data } and driver-sql SETs only present keys. But the
dropped key here is nested one level BELOW the key the write path merges
on: the body still carries the composite's own key, a location value is a
single JSON column, and nothing deep-merges, so the whole value is replaced
and the cleared coordinate does not come back. No silent data loss found.

A legitimate 0 coordinate is unaffected and now pinned: the emptiness test
reads the raw input string, and '0' is not an empty string.

GeolocationValue widens latitude/longitude/accuracy to `number | null`.

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

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3186.1 KB 3191.4 KB
Main entry chunk (gzip) 143.6 KB 350 KB
Entry file index-CunrkIsD.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) 15.67KB 5.75KB
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) 5.13KB 2.35KB
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) 497.06KB 113.79KB
core (index.js) 6.96KB 2.79KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 182.08KB 50.62KB
fields (index.js) 242.43KB 61.25KB
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) 4.28KB 1.75KB
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) 11.71KB 4.29KB
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) 5.12KB 1.74KB
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) 15.16KB 3.68KB
plugin-calendar (index.js) 47.29KB 13.18KB
plugin-charts (index.js) 70.43KB 19.71KB
plugin-chatbot (index.js) 193.53KB 46.05KB
plugin-dashboard (index.js) 131.41KB 34.43KB
plugin-designer (index.js) 211.51KB 43.01KB
plugin-detail (index.js) 247.59KB 63.48KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 131.01KB 32.32KB
plugin-gantt (index.js) 167.16KB 40.99KB
plugin-grid (index.js) 208.56KB 56.63KB
plugin-kanban (index.js) 52.30KB 14.49KB
plugin-list (index.js) 113.24KB 27.66KB
plugin-map (index.js) 20.35KB 6.77KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 29.95KB 8.67KB
plugin-tree (index.js) 9.19KB 3.19KB
plugin-view (index.js) 84.33KB 20.75KB
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) 5.41KB 2.34KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
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) 10.35KB 3.60KB
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.74KB 1.41KB
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 (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-steve
os-steve marked this pull request as ready for review September 6, 2026 13:42
@os-steve
os-steve added this pull request to the merge queue Sep 6, 2026
Merged via the queue into main with commit 0caacca Sep 6, 2026
33 of 34 checks passed
@os-steve
os-steve deleted the claude/issue-6848-geolocation-clear-emits-null branch September 6, 2026 13:57
baozhoutao pushed a commit that referenced this pull request Sep 6, 2026
Brings in batch 22 (#8063, packages/layout/README.md leaves the ledger) and
#8056, which moves packages/fields source and therefore the built types this
corpus is judged against. Forced build, gates, probes and tests re-run on the
merged head.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr
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): clearing a GeolocationField coordinate emits undefined, where the other three type="number" widgets emit null

2 participants