Skip to content

fix(fields): parse LocationField coordinates as strict whole-string numbers - #6766

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-6715-location-strict-numeric-parse
Aug 29, 2026
Merged

fix(fields): parse LocationField coordinates as strict whole-string numbers#6766
os-sales merged 1 commit into
mainfrom
claude/issue-6715-location-strict-numeric-parse

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes #6715

The defect

LocationField read each half of the typed pair with a bare parseFloat, which stops at the first character it cannot read and returns what it got. So "12abc, 34" emitted { lat: 12, lng: 34 } — a coordinate nobody typed.

The platform validator cannot be the oracle here, and that is what separates this card from objectui#6714. Every one of those truncations is a pair valueSchemaFor({ type: 'location' }) ACCEPTS: well-formed, in range, and wrong. #6714's 999, 999 was at least a value the contract refuses, so something downstream could in principle have objected. A truncation is a value the contract blesses.

Reproduced first, on the base commit b76ca6764

Driving a real ObjectForm (create mode, a type: 'location' field, a fake DataSource), typing and submitting:

typed "12abc, 34"    create({ place: {"lat":12,"lng":34} })    aria-invalid=false
typed "1.2.3, 4"     create({ place: {"lat":1.2,"lng":4} })    aria-invalid=false
typed "12deg, 34"    create({ place: {"lat":12,"lng":34} })    aria-invalid=false
typed "0x10, 34"     create({ place: {"lat":0,"lng":34} })     aria-invalid=false
typed "12 34, 56"    create({ place: {"lat":12,"lng":56} })    aria-invalid=false
typed "12.5 N, 34 E" create({ place: {"lat":12.5,"lng":34} })  aria-invalid=false
typed "1e, 2"        create({ place: {"lat":1,"lng":2} })      aria-invalid=false

The last rows show the size of the class, and neither was on the card:

  • 0x10 truncates to 0 — objectui#6272's || 0 in the Gulf of Guinea, arriving through a different door.
  • "12.5 N, 34 E" — the PASTE shape triage guessed the real user route to be — drops the hemisphere. A 12.5 S paste would have been stored as +12.5, on the wrong side of the equator, with nothing said.

The ruling this implements

⚖️ RULED — 2026-08-29, maintainer, live director session batch #11, verbatim: 「同意」 — adopting refuse: LocationField parses the typed pair as strict whole-string numbers — text carrying non-numeric residue (12abc, 1.2.3, 12deg) is a non-coordinate and is refused loudly, never silently truncated into a plausible wrong location.

⛔ Degree/hemisphere notation parsing is deliberately NOT ruled and is NOT built here. 12°N, 12.5 N, 12° 30' N all stay refused. The last describe block of the widget pin is the guard that it was not smuggled in, and the refusal message deliberately does not advertise a notation this widget will not read.

What changed

One file: packages/fields/src/widgets/LocationField.tsx.

The numeric test is parseFloat's own grammar, ANCHORED — not a stricter notion of a number invented in the widget:

const WHOLE_NUMBER_TEXT = /^[+-]?(?:Infinity|(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)$/;

parseFloat reads the longest PREFIX matching this grammar and discards the rest; the anchors turn "there is a number at the front" into "the whole text IS that number". parseFloat still supplies the value, so nothing about the reading itself moved.

Number() is not the test, although it looks like the same idea: it reads '0x10' as 16, '0b11' as 3 and the empty string as 0. A hex literal is not a coordinate notation, and none of those readings is what was typed.

ParsedDraft gains a fourth outcome, residue, and handleChange a third arm that announces through the same setRefusalError machinery objectui#6716 landed rather than a new one — that sequencing is why this card was held. A third silent refusal would have re-opened the defect #6716 had just removed, on a third input class. The message names the half it could not read:

Not saved: latitude "12abc" is not a number. Enter plain decimals (example: 30.2741, 120.1551).

Nothing in the refusal/draft machinery was restructured, renamed or simplified.

The boundary, stated explicitly

This is the whole card, so it is stated here rather than left to the regex. Each half of the pair is judged in this order:

The half Disposition Arm
No number at the front at all — abc, NaN, here, --1, (12) refused format arm, sentence UNCHANGED
A number, then leftover text — 12abc, 1.2.3, 12deg, 0x10, 0b11, 0o17, 1_000, 1e, 1.2e, 12 34, 1-2, 12°, 12.5 N refused residue arm, new
The whole text is a number — 12, 12.5, .5, 30., -12, +12, 1e3, 1E3, 1.5e-3, 1.e3, -.5 accepted emits, as today
Infinity, +Infinity, -Infinity refused range arm, UNCHANGED

Two of those rows are deliberate decisions rather than fallout:

  • Text with no number at the front keeps the pre-existing format sentence. "No number at all" and "a number with text after it" are different mistakes and deserve different advice. Keeping the split also keeps every objectui#6716 pin saying exactly what it said.
  • Infinity carries no residueparseFloat reads the whole word — so the new gate has nothing to say about it and it goes on to objectui#6714's range arm, which is where that card put it and what its docblock in this same file still describes. A strictness that swallowed it here would have silently invalidated a minutes-old explanation while keeping every "no emission" pin green. There is a pin for exactly this.

Whitespace is unaffected: the halves were already trimmed, so leading and trailing space around each half and around the pair still work.

Tests

Two new files, 59 new tests.

  • packages/fields/src/__tests__/LocationField.strictNumeric.test.tsx — 45 tests: the card's three inputs, the wider truncation class, the accepted-forms table above, the objectui#6716 arms pinned as undisturbed, and the ruling's degree/hemisphere fence.
  • packages/plugin-form/src/ObjectForm.locationResidue.test.tsx — 14 tests: the same refusals measured where the defect is actually observable, at what a real ObjectForm hands to dataSource.create. Each case asserts the PREMISE from the spec first — that the old reading produced a pair the platform accepts — so the file states why no downstream check could have caught it.

Ablation, against the committed implementation ec938a22b

The strictness was reverted by weakening the grammar to /^[\s\S]*$/ (the const stays referenced, so the mutation reaches dist too), with the restore trapped on EXIT INT TERM at an absolute path and pinned to HEAD.

### HEAD under ablation: ec938a22b74a8329b36c1d3656d17aa030205380
mutation: anchor replaced
src after mutation: injected=1 (want 1)  removed-grammar=0 (want 0)
build(mutated) exit=0
dist after mutation: removed-grammar=0 (want 0)
vitest(mutated) exit=1
restore: git-diff-HEAD-bytes=0 (want 0)
restore: blob=3bf3a6172b4ab3aebe45a79f626668efa0735cd7
restore: HEAD=3bf3a6172b4ab3aebe45a79f626668efa0735cd7
build(restored) exit=0
dist after restore: grammar-present=1 (want 1)
vitest(restored) exit=0

The mutation was confirmed on disk by grepping for the injected text and for the removed grammar — never by the editor's exit code — and again in dist after each leg's rebuild. The restore is proven by observed state: git diff HEAD is zero bytes and the file's blob hash equals that path's HEAD blob hash, both non-empty.

It discriminates. Mutated: Test Files 2 failed | 4 passed (6), Tests 27 failed | 77 passed (104). Every one of the 27 is a residue pin. Zero clean-pair pins moved, and neither objectui#6714's nor objectui#6716's files moved at all — the "still accepts every WHOLE-STRING number" table, the range file and the refusal-diagnostic file stayed green throughout. The end-to-end failure reads exactly like the defect:

AssertionError: expected { lat: 12, lng: 34 } to be undefined

Restored: Test Files 6 passed (6), Tests 104 passed (104).

Union, on the final commit ec938a22b

pnpm exec vitest run packages/fields/ packages/plugin-form/
Test Files  194 passed (194)
     Tests  2727 passed (2727)

Type-check, both packages, chained with && so the verdict covers both: exit 0. Both packages spell it type-check, and both echoed tsc --noEmit && tsc -p tsconfig.test.json, so neither silently matched zero scripts. The new test files were confirmed to be real program inputs with --listFiles (1 hit each) — a package tsconfig.json here excludes **/*.test.tsx from the build program, so "type-check is clean" would otherwise have said nothing about them.

Gates, each captured by redirect-then-capture, never after a pipe — all exit 0:

check:spec-symbols · check:spec-floors -- --cross-check · check:control-bytes · check:i18n-keys · check:i18n-dead-keys · check:readme-exports · check:self-import · check:phantom-deps · check:side-effects-array · check:vi-mock-specifiers · check:doc-fences · check:doc-snippets · check:doc-types · check:esm-specifiers · check:sdui-registration-pins · check:published-dist · check:eager-closure · lint:coverage · type-check:coverage · check-changeset-no-major

Quoting the two that matter most, in their own words:

✅  spec symbol derivation: 1320 files scanned against 4959 spec export names; 13 declared dialects, 3 untriaged collisions in 1 packages.
✅  Every consumer-facing @objectstack/spec floor carries the symbols its package's artifact references.

check:spec-floors and check:readme-exports first ran on a partly-built tree and reported their own unmet precondition (no-artifact ... produced no build output to judge, and the population COLLAPSED -- this run proves nothing). Those runs are recorded as NOT MEASURED, not as red; the full workspace was then built and both returned a real green, with check:readme-exports reporting 0 unbuilt and 37 of 40 packages read.

Lint: a declared narrowing

eslint . was run for the two packages this diff touches — 304 files judged, 0 errors (fields 197, plugin-form 107, counted from --format json) — rather than all 46 packages.

Three readings make that a measurement rather than a gap: the population is lint:coverage's own census, ✅ lint coverage: 46/46 packages linted, 0 with outstanding errors (0 total); the file counts come from eslint's own JSON output, not from a guess about which files count; and the config cannot carry the diff outward — eslint.config.js is untouched by this branch and configures no type-aware linting (no projectService, no parserOptions.project), so a change inside two packages cannot move any untouched package's verdict. CI runs the full farm regardless.

Scope

  • buildValidationRules gets no location branch. objectui#6744 owns that question, and packages/plugin-form/src/ObjectForm.locationRange.test.tsx was treated as read-only; its no-branch docblock stays true, and there is a pin for it.
  • ⛔ objectui#6714 is not reversed. Its pins are untouched and stayed green through the ablation.
  • ⛔ No new published surface. @object-ui/fields exports nothing new; WHOLE_NUMBER_TEXT, COORDINATE_LABELS, ResidueHalf and refusedResidueMessage are all module-local.
  • content/docs/releases/ untouched. A changeset is included.

Out of scope, recorded not fixed

  • objectui#6765 — filed from this card. CurrencyField and PercentField use the same parseFloat reading with no whole-string guard, relying entirely on browser type="number" sanitization, which happy-dom (this package's test environment) does not implement. Measured, not reproduced as a user-visible defect.
  • objectui#6755 — already open. The new refusal sentence is hard-coded English, the same shape as the two objectui#6716 added; that card is in the decision box and covers this one too, so nothing new was filed.

Generated by Claude Code

…umbers

A bare `parseFloat` stops at the first character it cannot read and returns
what it got, so each half of the typed pair was accepted as if it were whole:
`"12abc, 34"` emitted `{ lat: 12, lng: 34 }` — a coordinate nobody typed.

Unlike objectui#6714, the platform validator cannot be the oracle: every one
of those truncations is a pair `valueSchemaFor({ type: 'location' })` accepts,
so nothing downstream could ever object. Measured on b76ca67 through a real
ObjectForm, `dataSource.create` was handed `{"lat":12,"lng":34}` with
`aria-invalid="false"` and no diagnostic. The class is wider than the card's
three: `0x10` truncates to `0`, and `"12.5 N, 34 E"` drops the hemisphere.

Each half is now tested against `parseFloat`'s own grammar, ANCHORED — not a
stricter notion of a number invented in the widget — so every form that is a
number today still is: negatives, a leading `+`, surrounding whitespace,
exponent forms, and a bare decimal point on either side. The refusal is
announced through objectui#6716's `refusalError` machinery and names the half
it could not read; a third silent refusal would have re-opened the defect
#6716 had just closed.

Two boundaries drawn deliberately: text with no number at the front keeps the
pre-existing format sentence, and `Infinity` carries no residue so it is still
refused by #6714's range arm. Degree/hemisphere notation is not parsed, per
the maintainer ruling of 2026-08-29.

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

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 48 chunks) 3180.2 KB 3222.7 KB
Main entry chunk (gzip) 148.2 KB 350 KB
Entry file index-CE3lUd6N.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) 11.89KB 4.50KB
app-shell (runtime-config.js) 20.61KB 7.35KB
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) 511.66KB 116.30KB
core (index.js) 5.30KB 2.13KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 173.10KB 47.96KB
fields (index.js) 240.93KB 60.76KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
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.44KB 1.39KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 33.40KB 8.71KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.95KB 10.97KB
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.55KB 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.72KB 0.42KB
mobile (useResponsiveConfig.js) 1.37KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 9.53KB 3.38KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.64KB 1.50KB
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) 1.93KB 0.88KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.89KB 12.91KB
plugin-charts (index.js) 64.66KB 18.32KB
plugin-chatbot (index.js) 190.33KB 45.10KB
plugin-dashboard (index.js) 133.44KB 34.48KB
plugin-designer (index.js) 212.87KB 43.19KB
plugin-detail (index.js) 245.43KB 62.45KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 132.96KB 32.63KB
plugin-gantt (index.js) 165.20KB 40.37KB
plugin-grid (index.js) 201.53KB 54.54KB
plugin-kanban (index.js) 53.11KB 14.62KB
plugin-list (index.js) 113.11KB 27.58KB
plugin-map (index.js) 20.17KB 6.66KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.51KB 11.94KB
plugin-timeline (index.js) 26.98KB 7.78KB
plugin-tree (index.js) 9.00KB 3.08KB
plugin-view (index.js) 85.87KB 21.12KB
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) 69.47KB 23.06KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 2.44KB 1.21KB
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 0.99KB
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 (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.72KB 2.24KB
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 (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) 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

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): LocationField accepts a partly-numeric coordinate, emitting a plausible wrong location

2 participants