Skip to content

docs(fields): record the measured browser reading behind CurrencyField/PercentField - #6777

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-6765-number-input-sanitization
Aug 29, 2026
Merged

docs(fields): record the measured browser reading behind CurrencyField/PercentField#6777
os-sales merged 1 commit into
mainfrom
claude/issue-6765-number-input-sanitization

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Part of #6765

Deliberately notFixes: this PR carries the measurement the card asked for and the
half of the answer that is not a judgment call. The other half — whether the silent drop
this measurement reproduced should be announced — is escalated below and needs a ruling,
so the card must stay open.

Step 1: the measurement the card explicitly did not make

#6765 measured that happy-dom, this package's test environment, does not implement the
HTML number-input value-sanitization algorithm, and concluded that any test written for
these two widgets exercises a code path no browser takes. It was explicit that it had
not measured the real browser, and had not reproduced a user-visible defect.

Measured now: Chromium 141.0.7390.37 via Playwright 1.62.1, executablePath
/opt/pw-browsers/chromium, driving the real widgets mounted from source in a real
page (a Vite harness, not committed), through every delivery route a user has —
keystrokes, Ctrl+V paste with a real clipboard on a secure origin, keyboard.insertText,
and a programmatic value set.

The real-browser table — the widgets, not a bare input

widgetroutetyped/pastedbox .valuebadInputemitted
currencykeyboard12abc"12"falseonChange(12)
currencypaste12abc"12"falseonChange(12)
currencykeyboard1.2.3"1.23"falseonChange(1.23)
currencypaste0x10"010"falseonChange(10)
currencykeyboard1e""trueonChange(null)
currencypaste1e""true(no call at all)
currencykeyboard(empty)""false(no call)
percentkeyboard12abc"12"falseonChange(0.12)
percentkeyboard1.2.3"1.23"falseonChange(0.0123)
percentpaste0x10"010"falseonChange(0.1)
percentkeyboard1e""trueonChange(null)

Programmatic sets sanitize exactly as the spec says: el.value = "12abc" leaves "".

Which fork this selects, and the correction it forces

Sanitization holds — and the card's predicted consequence does not.

The card reasoned that if sanitization held, residue would degrade to the empty string and
onChange(null). Measured, 12abc / 1.2.3 / 0x10 never take that route. The browser
filters the keystroke or the paste before the change event fires, so what arrives is a
different, already well-formed number: "12", "1.23", "010".

The load-bearing consequence: residue never reaches these two widgets in a real
browser.
#6715's anchored WHOLE_NUMBER_TEXT — the right answer for LocationField,
a type="text" box with nothing filtering it — would here accept every string these boxes
can produce and reject only strings the test environment fabricates. Adding it would be a
no-op in the product whose only observable effect is making a happy-dom test go green over
a branch no user executes, and it would pin a truncation nobody reaches. That is the
outcome the dispatch fenced off, so it is not added.

The oracle really does disagree with the product

inputhappy-dom emitsreal Chromium emits
12abc (currency)1212
1.2.3 (currency)1.21.23
0x10 (currency)010
1e (currency)1null
1.2.3 (percent)0.0120.0123
0x10 (percent)00.1
1e (percent)0.01null

Six of ten cases disagree. A pin written in this environment asserting 0x10 yields 0
would assert the exact opposite of the product, which yields 10.

What is in this PR

  • CurrencyField.tsx, PercentField.tsx — the measurement recorded at the parseFloat
    sites, with the explicit note that finding(fields): LocationField accepts a partly-numeric coordinate, emitting a plausible wrong location #6715's guard is deliberately not copied and why.
    Comment-only: 78 inserted lines, 0 deletions, and every inserted line is a comment
    (git diff -U0 | grep -v '^+\s*\(\*\|//\|/\*\*\)' is empty). Behaviour unchanged.
  • NumberInputWidgets.environmentDivergence.test.tsx — 16 tests pinning the divergence:
    that happy-dom does not sanitize, that it nevertheless reports validity.badInput
    correctly, that the platform already calls every measured browser reading a whole number
    and every residue string bad input, and the per-widget oracle-vs-product table above.
  • An empty-frontmatter changeset — internal only, releases nothing (this repo's
    first-class way to declare that; the skip-changeset label is deliberately not used
    here, it reads nothing in this repo).

The unreachability assertion uses the platform's own validity.badInput rather than a
transcription of #6715's regex. That regex is module-private to LocationField.tsx, this
card is fenced out of editing that file to export it, and copying its source would create
the second dialect of "what a number is" AGENTS.md #0.1 forbids. badInput is also the
one signal on this surface that happy-dom and Chromium were measured to agree on.

Escalated, not fixed: the silent drop is real

The card listed "whether a silent drop is worth announcing" as unmeasured. The drop itself
is now reproduced: typing 1e leaves Chromium visibly displaying 1e while
.value reads ""; the widget emits null, aria-invalid stays "false", and zero
diagnostics render. Pasting 1e into an empty box is worse — React sees no value change
and fires nothing at all, so the model keeps its previous value while the box shows text
that contradicts it. That is #6716's class.

It is not fixed here, for two measured reasons rather than as a scope dodge:

  1. The same drop belongs to everytype="number" widget in this package —
    NumberField and GeolocationField read the box the same way. Announcing in two of
    four is a widget-class decision, not a patch.
  2. The truncating rows (1.2.3 becoming 1.23) cannot be refused by any widget-side
    guard at all
    — the information is gone before handleChange runs. Announcing on
    badInput while 1.2.3 still stores silently teaches users that no warning means the
    value is right, which is exactly when it is not. Refusing those too means abandoning
    type="number" for type="text" plus own parsing, which reverses Record-level inline edit polish (follow-up to #2407): expanded-value passthrough, approval-lock preflight, numeric inputs, edit-CTA state, keyboard shortcuts #2572's deliberate
    min/max/step affordances.

Options and a recommendation are in the report on #6765.

Verification

All heavy runs serialized through the shared verify lock. Union re-run on the shipping
commit 816e3b824, chained with && so the wrapper verdict certifies every part.

commandresult
pnpm exec vitest run packages/fields/Test Files 119 passed (119) · Tests 1972 passed (1972)
pnpm exec vitest run packages/fields/src/__tests__/NumberInputWidgets.environmentDivergence.test.tsxTests 16 passed (16)
tsc --noEmit + tsc -p tsconfig.test.json (packages/fields)both clean, empty output
pnpm run check:control-bytescheck-control-bytes: OK (scanned 5631 tracked text file(s))
pnpm run check:vi-mock-specifierscheck-vi-mock-specifiers: OK (3953 tracked source file(s))
node scripts/check-lint-coverage.mjslint coverage: 46/46 packages linted, 0 with outstanding errors (0 total)
node scripts/check-changeset-presence.mjs3 source file(s) ... declares 1 changeset(s) (empty frontmatter accepted)
node scripts/check-changeset-no-major.mjsNo changeset declares a major bump

The dependency closure was built first (pnpm --workspace-concurrency=2 --filter "@object-ui/fields^..." build) — without it tsc reports 100+ TS2307 Cannot find module
errors that are a fresh-worktree prerequisite failure, not a red gate.

Ablation, run on this commit to show the new pin measures the widget rather than
restating a constant table: swapping parseFloat for Number in CurrencyField's change
handler. Mutation confirmed on disk by grep counts before reading anything
(parseFloat-form 1 to 0, Number-form 0 to 1); no rebuild needed and none skipped,
because the test imports ../widgets/CurrencyField — a relative source import, with no
dist/ in the resolution path. Inner vitest exit 1, red on exactly the four currency
cases: expected NaN to be 12, expected NaN to be 1.2, expected 16 to be +0 (the
Number('0x10') reading #6715 rejected on measurement), expected NaN to be 1. Percent
untouched, as predicted. Restored under a trap ... EXIT INT TERM with absolute paths via
git checkout HEAD -- "$REPO_ROOT/...", and the restore proven by an empty
git diff HEAD, not by an exit code.

Generated by Claude Code


Generated by Claude Code

…d/PercentField
objectui#6765 filed that both widgets hand `parseFloat(e.target.value)` to
`onChange` with no whole-string guard, leaning entirely on the browser's
number-input value sanitization -- and that happy-dom, this package's test
environment, does not implement it. The card explicitly did NOT measure the real
browser. Measured now, on Chromium 141.0.7390.37 via Playwright, driving the real
widgets mounted in a real page:
typed "12abc" -> box.value "12" onChange(12)
typed "1.2.3" -> box.value "1.23" onChange(1.23)
pasted "0x10" -> box.value "010" onChange(10)
typed "1e" -> box.value "" onChange(null) validity.badInput
Residue never reaches these widgets: the browser filters the keystroke or paste
BEFORE the change event, so objectui#6715's anchored `WHOLE_NUMBER_TEXT` guard
would accept every string these boxes can produce and reject only strings the
test environment fabricates. It is deliberately not copied here, and both
widgets now say so where the next reader will look. No behaviour change -- the
product diff is 78 inserted comment lines and 0 deletions.
`NumberInputWidgets.environmentDivergence.test.tsx` pins the oracle-vs-product
disagreement (three of five readings differ) so it cannot be re-derived by
accident. It asserts the unreachability through the platform's own
`validity.badInput` -- the one signal happy-dom and Chromium were measured to
agree on -- rather than transcribing #6715's module-private regex, which would
be a second dialect of "what a number is" and would need an edit to
LocationField.tsx that this card is fenced out of.
Empty-frontmatter changeset: internal only, releases nothing.
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

MetricValueBudget
Eager closure (gzip, 48 chunks)3180.6 KB3222.7 KB
Main entry chunk (gzip)148.2 KB350 KB
Entry fileindex-EQTsU7c8.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)11.89KB4.50KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)511.71KB116.32KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.10KB47.96KB
fields (index.js)240.93KB60.76KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.33KB45.10KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)245.46KB62.46KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.03KB32.64KB
plugin-gantt (index.js)165.23KB40.37KB
plugin-grid (index.js)201.57KB54.55KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)27.22KB7.83KB
plugin-tree (index.js)9.00KB3.08KB
plugin-view (index.js)85.87KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)73.09KB24.34KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)2.44KB1.21KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-sales@claude