Skip to content

fix(core): drop a saved view row cap the contract refuses at the lowering layer, and say so on the path with no renderer (objectui#9928) - #10014

Merged
os-tesla merged 3 commits into
mainfrom
claude/issue-9928-saved-view-limit
Sep 19, 2026
Merged

os-tesla merged 3 commits into
mainfrom
claude/issue-9928-saved-view-limit

Conversation

@os-tesla

Copy link
Copy Markdown
Collaborator

Fixes #9928

savedViewLimit admitted its carrier on typeof … === 'number' alone, so a saved view's
pagination.pageSize — or its legacy flat limit — of 0, -10 or 25.5 lowered unchecked
into composed.limit. Both ends of that journey are declared positive; the lowering layer in
between asked nothing.

FIRST DELIVERABLE — the population measurement, with its control

Taken on the branch base 9f7e8463c, over git ls-files (8158 files): every strict-JSON file
parsed and walked as a schema tree (548 files), every JSON fence in .md/.mdx parsed the same
way (255 fences), and a literal scan across .ts/.tsx/.js/.jsx/.mjs/.cjs (5161 files). Usability
predicate, restating the one the repaired read points spell: a number, an integer, greater than
zero.

A. The card's own population — carriers reached through a saved view

carrier total CONTROL: legitimate spec-refused
saved view pagination.pageSize 0 0 0
saved view flat limit 0 0 0

The control is DARK here, so this zero is NOT a reading. The tree contains exactly two
authored saved views, both in examples/schema-catalog/src/schemas/plugin-view/object-view-named-views.json,
and neither carries a row cap in any spelling. There is no denominator, so "no saved view carries a
refused cap" is a statement about an instrument that found nothing at all — exactly the dead
instrument the dispatch warned about. Reported as dark rather than dressed up as a zero.

B. The nearest LIT instrument — the same carriers in any metadata position

carrier total CONTROL: legitimate spec-refused
metadata pagination.pageSize 9 9 0
metadata flat pageSize 16 16 0
metadata flat limit 11 11 0

The control is lit in every row. These three numbers were re-taken independently here and land on
the same values PR #10009 reported for its own run, which is corroboration rather than a relay.

C. Source literals

pageSize: 197 found, 176 legitimate, 21 refused — 18 in test files, and the 3 outside them are
guard internals in RelatedList, ObjectGrid and ListView (the literal 0 inside a
positivity check), not authored metadata. limit: 149 found, 122 legitimate, 27 refused — 14 in
tests, the other 13 the same kind of guard internal plus the ESLint rule corpus.

zero refused authored metadata, zero published metadata a user could have copied. The
re-grade switch in the dispatch (non-zero AND published) does not fire, so the rule lands.

What actually happens — measured on BOTH consumers, which do not agree

composed.limit has two consumers, and the family has so far only looked at one of them.

composed.limit at the fetcher who says anything
before, view pagination.pageSize: 0 0 limit: 0 nobody
after absent absent ViewDataProvider, once

ViewDataProvider.resolveElementDataSource forwards this key straight to
DataFetcher.fetchRecords and has no guard of its own. Measured on the unmodified tree,
0, -10 and 25.5 all reached the fetcher verbatim. Through a renderer the story is the
other one the dispatch carries — a block with its own guard drops the value and draws its own
default, so the named view's cap goes missing and the read goes wider.

So both directions in this family are real, on different paths. The corrected direction the
dispatch carries ("the cap is DROPPED and the read goes WIDER") is the renderer path. On the
ViewDataProvider path there is no renderer, nothing guards, and the original direction holds
literally: a starved read. Neither reading is wrong; they describe different consumers.

Drop, clamp or refuse — and the evidence

DROP.

Clamp is refused because this layer has no default to clamp to. Every consuming block owns
its own default and ViewDataProvider owns none, so a number invented here would override a
default the author never asked it to — and quietly substituting a number the author never wrote
is the quieter half of this same defect.

Throwing is refused because composeElementDataSource is pure and sits under every block
that can be bound to a view; a throw takes out the page over one declaration, which is worse than
the defect.

⇒ an absent limit is the honest statement that the view supplied no usable cap, and it is the
one thing every consumer already handles.

Does PR #10009's argument transfer to this layer? Partly — and the part that does not is mechanical

The conclusion transfers. #10009's reasoning was: after the repair the renderer that used to
report the refused value receives something it accepts, goes correctly silent, and the repair
therefore removes the only place the author was being told — so the layer that makes the decision
is the layer that reports it. The same thing happens one layer down. Measured at the gate, on both
sides of this branch's guard:

case node pagination.pageSize before after gate messages, before → after
view declares 0, component declares nothing 0 absent 0 → 0
view declares 0, component declares 50 50 50 0 → 0
CONTROL: view 7, component 0 (the case #10009 repaired) 7 7 1 → 1
CONTROL: view 7, component declares nothing 7 7 0 → 0

⭐ Two things that reading settles, and neither was assumed:

  1. The gate never reported a refused VIEW cap, before or after. Its diagnostic is about a
    component's refused cap being displaced by a usable one, and it returns "nothing to say" when
    the component authored nothing. So this branch does not silence a gate message — there was none.
  2. It does silence the renderer's. The node key goes from 0 to absent, and the renderer's own
    diagnostic treats an absent key as "not a mistake". That is fix(react): a row cap the contract refuses is not "authored" in the shared gate (objectui#9899) #10009's structure exactly, one layer
    down: the repair removes the only place the author was being told.

The mechanism does NOT transfer, and this is the part where "I did what the neighbour did"
would have been wrong. #10009 emits from a React effect because the gate is a component.
composeElementDataSource is a pure function and the render path calls it from a useMemo, so
a warning emitted inside it would fire during render and fire again on every re-render — the very
thing #10009 avoided.

⇒ this layer follows the shape its own module already established for its other refusal:
elementDataSourceViewNotFoundMessage is a pure builder, exported here so that every caller
reports the same defect the same way. New export elementDataSourceRefusedLimitMessage is the same
shape, and ViewDataProvider — the consumer with no guard, the one with a real victim — reports it
on the same console.warn channel the repaired read points use. It is fail-soft: records still
load, so an error would be worse than the defect.

⚠️ What this PR leaves open, and it is not tidy

On the renderer path the author now hears nothing about a refused view cap. The builder exists
and is wired on the path inside this card's fence; the gate would have to adopt it for the other
path, and the gate is packages/react — explicitly out of this card's fence and #10009's own file,
landed hours ago. ⛔ Not silently absorbed: it is called out here and reported for filing.

Tests

Reverse verification is a real pair, not a re-run. The guard's positivity half was ablated on disk
(the Number.isInteger(value) && value > 0 conjunction removed, leaving every export in place, so
the red is about the rule and not about a missing import), the injected and removed text each
counted on disk before the run, and the restore proven by blob hash against HEAD rather than by
an exit code.

  • pnpm exec vitest run packages/core/src/data-scope/5 files, 135 tests passed.
    With the guard ablated: 19 failed, 12 passed. Every name carrying CONTROL or SILENCE
    is among the 12 — checked mechanically, not by eye.
  • ⚠️ The ablation earned its keep: it caught an assertion in this PR's own new file that was
    named a control while also asserting the drop, so it went red with the guard removed. Split in
    a follow-up commit on this branch rather than relabelled.
  • pnpm exec vitest run packages/core/ packages/react/248 files, 4477 tests passed (on the
    merge with main that brings in fix(react): a row cap the contract refuses is not "authored" in the shared gate (objectui#9899) #10009).
  • Consumers of the limit mapping: pnpm exec vitest run packages/plugin-list/ packages/plugin-grid/ packages/plugin-kanban/ packages/plugin-timeline/ packages/plugin-detail/ packages/plugin-form/
    604 files, 5966 passed, 1 skipped.
  • Every elementDataSource consumer suite plus packages/react/104 files, 1216 tests passed.
  • pnpm --filter @object-ui/core type-check and pnpm --filter @object-ui/react type-check — both
    clean, after pnpm --filter '@object-ui/react^...' build supplied the dependency dists.
  • pnpm exec eslint . — repo-wide, not narrowed: 5161 files, 0 errors, 13256 warnings, all
    pre-existing. The four changed files add zero warnings (the 13 on ViewDataProvider are
    pre-existing no-explicit-any, none on an added line). Run on the final head 5e65b4e85.
  • check:element-data-source-declaration, check:control-bytes, check:new-line-citations,
    check-changeset-presence, check-changeset-no-major — all exit 0 on 5e65b4e85.

Acceptance notes

To file (reproducible defect). The BINDING's own limit — the other operand of
config.limit ?? savedViewLimit(view) — is still admitted unchecked, so
dataSource: { object: 'account', limit: 0 } reaches fetchRecords as 0 on the same unguarded
path this PR repairs for the view's half. ⛔ Deliberately not fixed here: it raises a PRECEDENCE
question this card does not own — does a refused binding cap suppress the view's legitimate one? —
which is the same question #10009 answered for the component/view pair one layer up. Nothing in the
new test file asserts the current answer, so whoever settles it will not have to edit a pin that
endorsed it. Dedupe words: element data source binding limit non-positive,
config.limit zero lowered, dataSource limit positivity.

To file (author feedback regression, introduced here). See the open item above: the renderer
path loses its one diagnostic for a refused VIEW cap. The fix is the gate adopting
elementDataSourceRefusedLimitMessage; that file is out of this fence. Dedupe words:
gate refused view cap silent, ElementDataSourceGate view pageSize diagnostic,
refused view cap no reporter.

noted, not filed: the card body and the dispatch both cite the spec's positive limit
declaration at packages/spec/src/ui/page.zod.ts:234. That address is wrong twice over — there is
no packages/spec in this repo (the spec arrives as the dependency @objectstack/spec), and in
that file line 234 is a docblock about retired component types. The declaration itself is real and
was verified by content, at limit: z.number().int().positive().optional(). Carrier: the next
person to re-take that citation; this is the second time the family has shipped a file:line
written from memory, which is the thing AGENTS.md commandment #11 rules against.

noted, not filed: the vitest invocation guard refused a run here that appended
packages/react/src/element-data-source/__tests__/ElementDataSourceGate.test.tsx after
packages/react/ — positional filters take a union, so the narrower path added nothing. The guard
was right and its message named the fix. Carrier: nobody — recorded because the run it refused was
one this seat would otherwise have reported as a narrowed run.

🤖 Generated with Claude Code

https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq


Generated by Claude Code

`savedViewLimit` admitted its carrier on `typeof … === 'number'` alone, so a
saved view's `pagination.pageSize` — or its legacy flat `limit` — of `0`, `-10`
or `25.5` lowered unchecked into the composed `limit`. Both ends of that journey
are declared positive; the lowering layer in between asked nothing.

Measured on both consumers of the composed key, which behave differently:
through a renderer the refused value met a block with its own guard, so the
named view's cap went missing and the read went WIDER than the view asked for;
through `ViewDataProvider.resolveElementDataSource`, which forwards the key
straight to `DataFetcher.fetchRecords` with no guard of its own, `0`, `-10` and
`25.5` reached the fetcher verbatim.

The cap is now dropped. Clamping is refused because this layer has no default to
clamp to — every consuming block owns its own and `ViewDataProvider` owns none.
Throwing is refused because the composer is pure and sits under every bindable
block.

Dropping alone would be silent and would remove the only place the author was
being told, so `elementDataSourceRefusedLimitMessage` is the loud half: a pure
builder, following the shape `elementDataSourceViewNotFoundMessage` already
established here, reported by `ViewDataProvider` on the `console.warn` channel
the repaired read points use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq
The reverse verification caught it: one assertion named CONTROL also required
the cap to be dropped, so it went red with the guard ablated. Split into a real
control (the other composed keys survive a USABLE cap) and an unlabelled pin for
the drop, so that every name carrying CONTROL or SILENCE passes on both sides of
the guard — which is the property that makes them worth having.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq
Brings in objectui#10009 (the shared gate's row-cap authorship rule), the
immediate consumer of the key this branch changes.
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 329 chunks) 3055.9 KB 3104.5 KB
Main entry chunk (gzip) 145.7 KB 350 KB
Entry file index-B9X3ptbg.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) 545.92KB 130.72KB
core (index.js) 8.94KB 3.59KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 216.90KB 60.15KB
fields (index.js) 249.62KB 63.02KB
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) 8.87KB 3.64KB
i18n (index.js) 5.22KB 2.26KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 32.15KB 10.49KB
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.83KB 10.95KB
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) 5.52KB 2.10KB
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.92KB 14.22KB
plugin-charts (index.js) 71.73KB 20.08KB
plugin-chatbot (index.js) 195.35KB 46.52KB
plugin-dashboard (index.js) 132.96KB 35.17KB
plugin-designer (index.js) 215.94KB 44.33KB
plugin-detail (index.js) 255.18KB 66.49KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 139.56KB 35.40KB
plugin-gantt (index.js) 167.62KB 41.26KB
plugin-grid (index.js) 213.44KB 58.21KB
plugin-kanban (index.js) 48.71KB 15.17KB
plugin-list (index.js) 113.55KB 27.99KB
plugin-map (index.js) 21.48KB 6.99KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.41KB 11.93KB
plugin-timeline (index.js) 30.68KB 8.95KB
plugin-tree (index.js) 10.58KB 3.72KB
plugin-view (index.js) 85.18KB 21.05KB
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) 109.04KB 36.08KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.38KB 1.98KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.74KB 2.54KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 15.71KB 5.30KB
types (ai.js) 4.11KB 2.06KB
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.04KB 5.36KB
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-tesla
os-tesla marked this pull request as ready for review September 19, 2026 11:42
@os-tesla
os-tesla added this pull request to the merge queue Sep 19, 2026
Merged via the queue into main with commit 5eabe86 Sep 19, 2026
38 checks passed
@os-tesla
os-tesla deleted the claude/issue-9928-saved-view-limit branch September 19, 2026 12:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants