From f1568196ad5627b005e81d0eb9ab7a2daf40026b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 21:14:33 +0000 Subject: [PATCH 1/4] fix(core,react): a refused dataSource binding limit is not authored and yields to the view's cap (objectui#10016) The binding's own `limit`, the other operand of the row-cap chain, gets the positivity check objectui#9928 gave the saved view's. A refused binding cap (0, negative, non-integer, non-number) is not authored: the chain falls through to the view's usable cap, then to the consumer's own default. - composeElementDataSource: `bindingLimit(config) ?? savedViewLimit(view)`. - elementDataSourceRefusedLimitMessage: optional `binding` and `operand` parameters; the binding operand has its own sentence naming the binding, and the view operand is silent when the binding's usable cap is used. - ViewDataProvider reports both operands; its view warning no longer fires under a usable binding cap (the defect carried on the card). - ElementDataSourceGate: the view cap that replaces a refused binding cap is a baseline a usable component cap wins over, and the binding refusal is reported once per declaration from an effect. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC --- .../10016-binding-limit-not-authored.md | 40 +++ .../core/src/data-scope/ViewDataProvider.ts | 18 +- ...urce.bindingLimitNotAuthored-10016.test.ts | 248 ++++++++++++++++++ ...rce.savedViewLimitNonPositive-9928.test.ts | 14 +- .../src/data-scope/element-data-source.ts | 121 ++++++++- .../ElementDataSourceGate.tsx | 75 ++++-- .../__tests__/ElementDataSourceGate.test.tsx | 189 +++++++++++++ 7 files changed, 667 insertions(+), 38 deletions(-) create mode 100644 .changeset/10016-binding-limit-not-authored.md create mode 100644 packages/core/src/data-scope/__tests__/element-data-source.bindingLimitNotAuthored-10016.test.ts diff --git a/.changeset/10016-binding-limit-not-authored.md b/.changeset/10016-binding-limit-not-authored.md new file mode 100644 index 0000000000..3f1b33a71b --- /dev/null +++ b/.changeset/10016-binding-limit-not-authored.md @@ -0,0 +1,40 @@ +--- +'@object-ui/core': minor +'@object-ui/react': patch +--- + +A `dataSource` binding's own `limit` that the contract refuses is now treated as **not +authored**: the row cap falls through to the named saved view's usable cap, and only when +that is absent too to the consumer's own default (objectui#10016). + +**Behaviour change.** `composeElementDataSource` resolved the cap as +`config.limit ?? savedViewLimit(view)`. objectui#9928 put a positivity check on the view's +operand only, so a binding `limit` of `0`, `-10`, `25.5` or a non-number went through +unchecked: + +- through `ViewDataProvider.resolveElementDataSource` it reached `DataFetcher.fetchRecords` + verbatim, and nothing said so; +- through `ElementDataSourceGate` it was written over everything, a usable component cap + included, and the consuming block then dropped it and drew its own default, so the read + went wider than either the view or the component asked for. + +Both operands now pass the same check, and a refused cap from either is not authored. The +chain is: a usable binding cap, else a usable view cap, else none. This is the rule +objectui#10009 set one layer up (a value the contract refuses is not authored, so the other +source wins), applied to the two operands of one resolver. On the renderer path the view's +cap that takes the binding's place is a baseline like any other view-sourced value, so a +usable component cap still wins over it. A usable binding `limit` still beats both, exactly +as before. + +**Diagnostics.** `elementDataSourceRefusedLimitMessage` takes two optional trailing +parameters, the binding and the operand (`'view'`, the default, or `'binding'`). The binding +operand has its own sentence, naming the binding, so a binding refusal and a view refusal +are told apart when both fire. `ViewDataProvider` and `ElementDataSourceGate` report it once +per declaration on the existing `console.warn` channel; the gate reports it only for a block +that reads a row cap. Called with three arguments the builder answers exactly as before. + +**Fixed with it.** `ViewDataProvider` reported a saved view's refused cap even when the +binding's own usable `limit` was the cap actually used, and that warning's claim that the +fetch falls back to a default was false. The view's refusal is now reported only when the +binding's `limit` is absent or refused. The builder applies that condition itself, so both +callers share one copy of it. diff --git a/packages/core/src/data-scope/ViewDataProvider.ts b/packages/core/src/data-scope/ViewDataProvider.ts index e623916d07..8c0dd872ea 100644 --- a/packages/core/src/data-scope/ViewDataProvider.ts +++ b/packages/core/src/data-scope/ViewDataProvider.ts @@ -362,8 +362,22 @@ export class ViewDataProvider { // channel those sites use. ⛔ Not an `error`: the refusal is FAIL-SOFT and // the records still load, so blanking the result would be a worse outcome // than the defect. - const refusedLimit = elementDataSourceRefusedLimitMessage(view, config.view, config.object); - if (refusedLimit) console.warn(refusedLimit); + // + // Both operands of the chain are asked (objectui#10016): a refused binding + // `limit` is not authored and yields to the view's cap, so it is reported + // in its own words. The builder is also handed the binding for the VIEW's + // sentence, because that sentence says the fetch falls back to a default, + // which is false when the binding's own usable cap is what gets used. + for (const operand of ['binding', 'view'] as const) { + const refusedLimit = elementDataSourceRefusedLimitMessage( + view, + config.view, + config.object, + config, + operand, + ); + if (refusedLimit) console.warn(refusedLimit); + } const fields = Array.isArray(composed.columns) ? composed.columns.filter((c): c is string => typeof c === 'string' && !!c) diff --git a/packages/core/src/data-scope/__tests__/element-data-source.bindingLimitNotAuthored-10016.test.ts b/packages/core/src/data-scope/__tests__/element-data-source.bindingLimitNotAuthored-10016.test.ts new file mode 100644 index 0000000000..fc6a691318 --- /dev/null +++ b/packages/core/src/data-scope/__tests__/element-data-source.bindingLimitNotAuthored-10016.test.ts @@ -0,0 +1,248 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * objectui#10016 — the BINDING's own `limit`, the other operand of the row-cap + * chain, gets the positivity check objectui#9928 gave the saved view's. + * + * Maintainer ruling, option A: a binding `limit` the contract refuses is NOT + * AUTHORED. It yields exactly as an absent one does, to the view's usable cap + * and then to the consumer's own default. One rule for both operands, the rule + * objectui#10009 set one layer up (a refused value is not authored, the other + * source wins). + * + * The file is the ruling's truth table, binding {absent, usable, refused} × + * view {no cap, usable, refused}, on the pure composer and on + * `ViewDataProvider`, the caller with no renderer and no guard of its own. The + * renderer caller, `ElementDataSourceGate`, pins the same rows in its own + * suite. + * + * Only the rows with a refused binding may change what is RESOLVED. Elsewhere + * only the warnings may change, and only in one row: a usable binding cap over + * a refused view cap. There the view's warning said the fetch falls back to a + * default, which was false because the binding's cap is what gets used. That + * is the defect carried on the card's thread (comment 5814132982). + * + * ⚠️ Every CONTROL row passes both BEFORE and AFTER the change. A change that + * stopped honouring any binding cap would pass the refused rows and fail them. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { + composeElementDataSource, + elementDataSourceRefusedLimitMessage, +} from '../element-data-source'; +import { ViewDataProvider, type DataFetcher } from '../ViewDataProvider'; + +/** + * What the contract refuses for a binding `limit`. The string is a value the + * binding's `??` used to pass through verbatim, so it is refused here too. + */ +const REFUSED: readonly unknown[] = [0, -10, 25.5, '20']; + +/** The view operand's three states. */ +const VIEW_NO_CAP = { label: 'Hot' }; +const VIEW_USABLE = { pagination: { pageSize: 25 } }; +const VIEW_REFUSED = { pagination: { pageSize: 0 } }; + +const bound = (limit?: unknown) => + ({ object: 'account', view: 'hot', ...(limit === undefined ? {} : { limit }) }) as { + object: string; + view: string; + limit?: number; + }; + +describe('objectui#10016 — a refused binding `limit` is not authored', () => { + describe('the composed `limit` — the truth table', () => { + it.each(REFUSED)('binding %s + view with no cap ⇒ no cap (the consumer default)', (bad) => { + const composed = composeElementDataSource(bound(bad), VIEW_NO_CAP); + expect(composed.limit).toBeUndefined(); + expect('limit' in composed).toBe(false); + }); + + it.each(REFUSED)('binding %s + usable view cap ⇒ the view’s cap', (bad) => { + expect(composeElementDataSource(bound(bad), VIEW_USABLE).limit).toBe(25); + }); + + it.each(REFUSED)('binding %s + refused view cap ⇒ no cap', (bad) => { + const composed = composeElementDataSource(bound(bad), VIEW_REFUSED); + expect(composed.limit).toBeUndefined(); + expect('limit' in composed).toBe(false); + }); + + it.each(REFUSED)('binding %s and no view at all ⇒ no cap', (bad) => { + const composed = composeElementDataSource({ object: 'account', limit: bad as number }); + expect(composed.limit).toBeUndefined(); + }); + + it('binding 0 + legacy flat view `limit` ⇒ the view’s cap (the view’s second carrier)', () => { + expect(composeElementDataSource(bound(0), { limit: 9 }).limit).toBe(9); + }); + + // ---------------------------------------------------------------- CONTROLS + it('CONTROL — usable binding + view with no cap ⇒ the binding’s cap', () => { + expect(composeElementDataSource(bound(3), VIEW_NO_CAP).limit).toBe(3); + }); + + it('CONTROL — usable binding + usable view cap ⇒ the binding’s cap', () => { + expect(composeElementDataSource(bound(3), VIEW_USABLE).limit).toBe(3); + }); + + it('CONTROL — usable binding + refused view cap ⇒ the binding’s cap', () => { + expect(composeElementDataSource(bound(3), VIEW_REFUSED).limit).toBe(3); + }); + + it('CONTROL — no binding cap: no view cap, a usable one, a refused one', () => { + expect(composeElementDataSource(bound(), VIEW_NO_CAP).limit).toBeUndefined(); + expect(composeElementDataSource(bound(), VIEW_USABLE).limit).toBe(25); + expect(composeElementDataSource(bound(), VIEW_REFUSED).limit).toBeUndefined(); + }); + + it('CONTROL — a `null` binding `limit` stays what `??` made it: absent', () => { + expect(composeElementDataSource(bound(null), VIEW_USABLE).limit).toBe(25); + }); + }); + + describe('the message — each operand in its own words', () => { + it.each(REFUSED)('the binding operand names the binding, the object and the refused %s', (bad) => { + const msg = elementDataSourceRefusedLimitMessage(VIEW_USABLE, 'hot', 'account', bound(bad), 'binding'); + expect(msg).not.toBeNull(); + expect(msg).toContain('binding on account'); + expect(msg).toContain(typeof bad === 'string' ? JSON.stringify(bad) : String(bad)); + expect(msg).toContain('positive integer'); + }); + + it('the binding operand speaks whatever the view carries', () => { + for (const view of [VIEW_NO_CAP, VIEW_USABLE, VIEW_REFUSED, undefined]) { + expect(elementDataSourceRefusedLimitMessage(view, 'hot', 'account', bound(0), 'binding')).not.toBeNull(); + } + }); + + it('both refused ⇒ two messages, and they are told apart by the operand they name', () => { + const binding = elementDataSourceRefusedLimitMessage(VIEW_REFUSED, 'hot', 'account', bound(0), 'binding'); + const view = elementDataSourceRefusedLimitMessage(VIEW_REFUSED, 'hot', 'account', bound(0), 'view'); + expect(binding).not.toBeNull(); + expect(view).not.toBeNull(); + expect(binding).not.toBe(view); + expect(binding).toContain('binding on account'); + expect(view).toContain('saved view "hot" on account'); + expect(view).not.toContain('binding on account'); + }); + + it('a usable binding cap silences the VIEW’s refusal, which changed nothing (the carried defect)', () => { + expect(elementDataSourceRefusedLimitMessage(VIEW_REFUSED, 'hot', 'account', bound(3))).toBeNull(); + expect(elementDataSourceRefusedLimitMessage(VIEW_REFUSED, 'hot', 'account', bound(3), 'view')).toBeNull(); + }); + + it('a refused binding cap does NOT silence the view’s refusal: neither supplied a cap', () => { + expect(elementDataSourceRefusedLimitMessage(VIEW_REFUSED, 'hot', 'account', bound(0))).not.toBeNull(); + }); + + // -------------------------------------------------------- SILENCE CONTROLS + it('SILENCE — the binding operand says nothing for an absent, `null` or usable `limit`', () => { + for (const limit of [undefined, null, 3]) { + expect(elementDataSourceRefusedLimitMessage(VIEW_REFUSED, 'hot', 'account', bound(limit), 'binding')) + .toBeNull(); + } + expect(elementDataSourceRefusedLimitMessage(VIEW_REFUSED, 'hot', 'account', undefined, 'binding')).toBeNull(); + }); + + it('SILENCE CONTROL — the view operand reads as before when no binding is passed', () => { + expect(elementDataSourceRefusedLimitMessage(VIEW_REFUSED, 'hot', 'account')).not.toBeNull(); + expect(elementDataSourceRefusedLimitMessage(VIEW_USABLE, 'hot', 'account')).toBeNull(); + expect(elementDataSourceRefusedLimitMessage(VIEW_REFUSED, 'hot', 'account', bound())).not.toBeNull(); + }); + }); + + describe('ViewDataProvider — the caller with no renderer and no guard of its own', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + /** Resolve one row: what reached the fetcher, and what was said. */ + const row = async (limit: unknown, viewConfig: Record | null) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const fetchRecords = vi.fn(async () => ({ records: [], total: 0 })); + const provider = new ViewDataProvider(); + provider.setFetcher({ + fetchRecords, + fetchViews: async () => ({ hot: viewConfig ?? {} }), + }); + const config = viewConfig === null + ? { object: 'account', ...(limit === undefined ? {} : { limit }) } + : bound(limit); + await provider.resolveElementDataSource(config as { object: string; limit?: number }); + const said = warn.mock.calls.map((c) => String(c[0])); + return { limit: fetchRecords.mock.calls[0]?.[1]?.limit, said }; + }; + + const bindingSentence = (bad: unknown) => + elementDataSourceRefusedLimitMessage(null, 'hot', 'account', { limit: bad }, 'binding'); + const viewSentence = elementDataSourceRefusedLimitMessage(VIEW_REFUSED, 'hot', 'account'); + + it.each(REFUSED)('binding %s + usable view cap ⇒ `limit` 25 and ONE binding warning', async (bad) => { + const { limit, said } = await row(bad, VIEW_USABLE); + expect(limit).toBe(25); + expect(said).toEqual([bindingSentence(bad)]); + }); + + it.each(REFUSED)('binding %s + view with no cap ⇒ no `limit` and ONE binding warning', async (bad) => { + const { limit, said } = await row(bad, VIEW_NO_CAP); + expect(limit).toBeUndefined(); + expect(said).toEqual([bindingSentence(bad)]); + }); + + it('binding 0 and no view named ⇒ no `limit` and ONE binding warning', async () => { + const { limit, said } = await row(0, null); + expect(limit).toBeUndefined(); + expect(said).toHaveLength(1); + expect(said[0]).toContain('binding on account'); + }); + + it('binding 0 + refused view cap ⇒ no `limit`, and one warning per refused operand', async () => { + const { limit, said } = await row(0, VIEW_REFUSED); + expect(limit).toBeUndefined(); + expect(said).toEqual([bindingSentence(0), viewSentence]); + }); + + it('binding 3 + refused view cap ⇒ `limit` 3 and NO view warning (the carried defect)', async () => { + const { limit, said } = await row(3, VIEW_REFUSED); + expect(limit).toBe(3); + expect(said).toEqual([]); + }); + + // ---------------------------------------------------------------- CONTROLS + it('CONTROL — no binding cap + refused view cap ⇒ no `limit` and the view warning, as before', async () => { + const { limit, said } = await row(undefined, VIEW_REFUSED); + expect(limit).toBeUndefined(); + expect(said).toEqual([viewSentence]); + }); + + it('CONTROL — binding 3 + usable view cap ⇒ `limit` 3, and nothing said', async () => { + const { limit, said } = await row(3, VIEW_USABLE); + expect(limit).toBe(3); + expect(said).toEqual([]); + }); + + it('CONTROL — binding 3 + view with no cap ⇒ `limit` 3, and nothing said', async () => { + const { limit, said } = await row(3, VIEW_NO_CAP); + expect(limit).toBe(3); + expect(said).toEqual([]); + }); + + it('CONTROL — no binding cap + usable view cap ⇒ `limit` 25, and nothing said', async () => { + const { limit, said } = await row(undefined, VIEW_USABLE); + expect(limit).toBe(25); + expect(said).toEqual([]); + }); + + it('CONTROL — no binding cap + view with no cap ⇒ no `limit`, and nothing said', async () => { + const { limit, said } = await row(undefined, VIEW_NO_CAP); + expect(limit).toBeUndefined(); + expect(said).toEqual([]); + }); + }); +}); diff --git a/packages/core/src/data-scope/__tests__/element-data-source.savedViewLimitNonPositive-9928.test.ts b/packages/core/src/data-scope/__tests__/element-data-source.savedViewLimitNonPositive-9928.test.ts index 124a40826e..b797fa1b84 100644 --- a/packages/core/src/data-scope/__tests__/element-data-source.savedViewLimitNonPositive-9928.test.ts +++ b/packages/core/src/data-scope/__tests__/element-data-source.savedViewLimitNonPositive-9928.test.ts @@ -24,13 +24,13 @@ * is deliberate: a change that simply stopped lowering any view cap at all * would satisfy the refusals and fail the controls. * - * ⚠️ KNOWN GAP, deliberately not pinned here: the BINDING's own `limit` — the - * other operand of `config.limit ?? savedViewLimit(view)` — is still admitted - * unchecked, so `dataSource: { object, limit: 0 }` still reaches the fetcher as - * `0`. That carrier raises a PRECEDENCE question this card does not own (does a - * refused binding cap suppress the view's legitimate one?), and it is reported - * rather than answered here. Nothing in this file asserts the current answer, - * so the card that settles it will not have to edit a pin that endorsed it. + * The BINDING's own `limit`, the other operand of the chain, was a known gap + * when this file was written, and it was deliberately left unpinned here + * because it raised a precedence question this card did not own. objectui#10016 + * settled it (a refused binding cap is not authored and yields to the view's), + * and its truth table lives in + * `element-data-source.bindingLimitNotAuthored-10016.test.ts`. Nothing in this + * file asserted the old answer, so nothing here had to be rewritten. */ import { describe, it, expect, vi, afterEach } from 'vitest'; diff --git a/packages/core/src/data-scope/element-data-source.ts b/packages/core/src/data-scope/element-data-source.ts index 8104e38b5d..cc2aa37639 100644 --- a/packages/core/src/data-scope/element-data-source.ts +++ b/packages/core/src/data-scope/element-data-source.ts @@ -42,15 +42,18 @@ * | `columns` | view only — the binding has no such key | view | * | `filter` | view + binding | AND-combined ("additional") | * | `sort` | view or binding | binding overrides view | - * | `limit` | view (`pagination.pageSize`) or binding | binding overrides view | - * - * The view's half of `limit` carries one extra condition the other keys do not: - * the destination is declared a POSITIVE INTEGER, so a view's cap the contract - * refuses is dropped rather than lowered. See `savedViewLimit` for why dropping - * beats clamping or throwing here, and `elementDataSourceRefusedLimitMessage` - * for the half that tells the author. + * | `limit` | view (`pagination.pageSize`) or binding | usable binding overrides view | * | `viewType` | view only | view | * + * Both operands of `limit` carry one extra condition the other keys do not: the + * destination is declared a POSITIVE INTEGER, so a cap the contract refuses is + * NOT AUTHORED, whichever operand carried it. One chain, one rule for both: a + * usable binding cap, else a usable view cap, else none (the consumer's own + * default). See `savedViewLimit` for why the view's refused cap is dropped + * rather than clamped or thrown, `bindingLimit` for why a refused binding cap + * yields to the view's (objectui#10016), and `elementDataSourceRefusedLimitMessage` + * for the half that tells the author, about either operand. + * * A lone `filter` — only the view has one, or only the binding does — is passed * through in the shape it was stored in; only the two-source case is lowered to * an ObjectQL AST, because that is what combining requires. @@ -106,9 +109,10 @@ export interface ComposedElementDataSource { /** Binding sort if given, else the view's. */ sort?: unknown; /** - * Binding limit if given, else the view's page size — and, from the view, - * only a cap the contract admits (`savedViewLimit` drops the rest, and - * `elementDataSourceRefusedLimitMessage` is what says so). + * The binding's limit if it is one the contract admits, else the view's page + * size under the same condition. A refused cap from either operand is not + * authored (`bindingLimit` and `savedViewLimit` apply the one rule), and + * `elementDataSourceRefusedLimitMessage` is what says so. */ limit?: number; /** The view's render kind (grid / kanban / …), when the view declares one. */ @@ -280,7 +284,86 @@ function savedViewLimit(view: ElementSavedView | null | undefined): number | und } /** - * The diagnostic half of {@link savedViewLimit}. `null` means "nothing to say". + * Read the binding's own row cap, and hand back only a cap the contract admits. + * + * The other operand of the `limit` chain gets the same positivity check as + * {@link savedViewLimit}, through the same predicate. Maintainer ruling on + * objectui#10016 (option A): a binding `limit` the contract refuses (`0`, a + * negative, a fraction, a value that is not a number at all) is NOT AUTHORED. + * It yields exactly as an absent one does, to the saved view's usable cap and, + * when that is absent too, to the consumer's own default. + * + * This is objectui#10009's precedent applied as a rule rather than re-decided: + * there, one layer up, a value the contract refuses was ruled not authored and + * the other source won. Here the same question is asked of the two operands of + * one resolver, and it gets the same answer. The repo therefore holds ONE + * precedence rule for "whose row cap is used", not one per pair of operands. + * + * The two answers the ruling refused, and why: + * + * - ⛔ dropping the binding's cap AND skipping the view's: a bad value on the + * binding would then discard the view's legitimate cap, which is the least + * explicable outcome for an author; + * - ⛔ refusing loudly and rendering nothing: a blank region at render time + * makes an author's slip expensive without making it any harder to write. + * The loud refusal belongs on the WRITE surface, not here. + * + * `null` stays what `??` always made it: absent. + */ +function bindingLimit(config: ElementDataSourceConfig): number | undefined { + return isUsableRowLimit(config.limit) ? config.limit : undefined; +} + +/** + * The binding operand's half of {@link elementDataSourceRefusedLimitMessage}. + * Module-private: callers reach it through that one exported builder, so there + * is still exactly one channel for a refused row cap, whichever operand it was. + * + * Unlike the view's message it needs no condition on the other operand. A + * usable binding cap is used whatever the view carries, so a refused one is + * ALWAYS a cap the consumer did not get, and saying so is always true. + */ +function refusedBindingLimitMessage( + binding: { limit?: unknown } | null | undefined, + viewName: string | undefined | null, + object: string, +): string | null { + const raw = binding?.limit; + if (raw === undefined || raw === null) return null; + if (isUsableRowLimit(raw)) return null; + const where = viewName + ? `dataSource binding on ${object} (view "${viewName}")` + : `dataSource binding on ${object}`; + // `String('20')` would print a string exactly like the number it is not. + const shown = typeof raw === 'number' ? String(raw) : String(JSON.stringify(raw)); + return ( + `[ObjectUI] ElementDataSource: the ${where} declares \`limit: ${shown}\`, ` + + 'which is not a positive integer. A row cap must be a positive integer ' + + '(the spec declares this binding’s `limit` positive), so the binding’s cap ' + + 'was treated as not authored and ignored, exactly as if the binding declared ' + + 'no `limit`: the cap comes from the next source that declares a usable one, ' + + 'or else from the consumer’s own default.' + ); +} + +/** + * The diagnostic half of {@link savedViewLimit} and {@link bindingLimit}. + * `null` means "nothing to say". + * + * ## Which operand + * + * `operand` picks the refusal to describe; it defaults to `'view'`, the one + * this builder described before objectui#10016. Each operand gets its own + * sentence, and each names its operand, so the two refusals stay + * distinguishable when both fire. They do fire together when the binding AND + * the view each carry a refused cap: two declarations, one message each. + * + * `binding` is the binding as authored. The view's sentence needs it too: it + * says the consumer falls back to its own default, and that is only true when + * the binding supplied no usable cap. A usable binding cap is used whatever the + * view carries, so a refused view cap under one changed nothing and is not + * reported. That condition lives HERE, once, rather than at each caller, + * because every caller needs it and two copies could drift. * * ## Why a BUILDER here, and not a warning from the composer * @@ -304,17 +387,24 @@ function savedViewLimit(view: ElementSavedView | null | undefined): number | und * reports it. * * ⛔ NOT a second guard: the predicate lives once, in {@link isUsableRowLimit}, - * and the carrier is read once, by {@link savedViewRawLimit}; this reads both. + * and the view's carrier is read once, by {@link savedViewRawLimit}; this reads + * both. * * ⚠️ It speaks only about a cap THIS layer dropped. A non-numeric * `pagination.pageSize` never became a limit here, before or after, so there is - * nothing for this layer to report about it. + * nothing for this layer to report about it. A non-numeric binding `limit` is + * different: `??` used to pass it through, so this layer now drops it and says + * so. */ export function elementDataSourceRefusedLimitMessage( view: ElementSavedView | null | undefined, viewName: string | undefined | null, object: string, + binding?: { limit?: unknown } | null, + operand: 'view' | 'binding' = 'view', ): string | null { + if (operand === 'binding') return refusedBindingLimitMessage(binding, viewName, object); + if (isUsableRowLimit(binding?.limit)) return null; const raw = savedViewRawLimit(view); if (raw === undefined) return null; if (isUsableRowLimit(raw)) return null; @@ -386,7 +476,10 @@ export function composeElementDataSource( const sort = config.sort ?? view?.sort; if (sort !== undefined) composed.sort = sort; - const limit = config.limit ?? savedViewLimit(view); + // One rule for both operands (objectui#10016): a refused cap is not + // authored, so a refused binding `limit` yields to the view's cap exactly as + // an absent one does. + const limit = bindingLimit(config) ?? savedViewLimit(view); if (limit !== undefined) composed.limit = limit; const viewType = savedViewType(view); diff --git a/packages/react/src/element-data-source/ElementDataSourceGate.tsx b/packages/react/src/element-data-source/ElementDataSourceGate.tsx index 212f6039f2..f2082ce4c4 100644 --- a/packages/react/src/element-data-source/ElementDataSourceGate.tsx +++ b/packages/react/src/element-data-source/ElementDataSourceGate.tsx @@ -66,6 +66,11 @@ * reports the view's refusal instead (objectui#10015). See * {@link describeRefusedViewRowLimit} for when that message applies. * + * The BINDING's own cap follows the same rule (objectui#10016): a `limit` the + * contract refuses is not authored, so `@object-ui/core` lets the view's cap + * through in its place, and that cap is a baseline here like any other + * view-sourced value. See {@link describeRefusedBindingRowLimit}. + * * ## What a mapping may NOT do * * {@link ElementDataSourceMapping} names only keys the target block genuinely @@ -253,8 +258,10 @@ const describeDisplacedRowLimit = ( * It speaks only when the refusal CHANGED what this block receives — when a * usable view cap would have been written here: * - * - the binding declared no `limit` of its own (if it did, the binding's cap - * is what the block gets whatever the view carries), and + * - the binding declared no usable `limit` of its own (if it did, the + * binding's cap is what the block gets whatever the view carries). The + * builder applies this condition itself, because `ViewDataProvider` needs + * it too (objectui#10016), so this relay hands it the binding; and * - the component's own cap is not a usable one (a usable one wins over any * view cap). * @@ -266,19 +273,46 @@ const describeDisplacedRowLimit = ( * ## Never together with {@link describeDisplacedRowLimit} * * That message needs a usable cap to have been WRITTEN. With a refused view - * cap, the only usable cap left is a binding's `limit`, and that is excluded - * above. So the two are exclusive by construction. When the component AND the - * view both carry a refused cap, this message names the view and the renderer - * names the component: two declarations, one message each. + * cap, the only usable cap left is a binding's usable `limit`, and that is + * excluded above. So the two are exclusive by construction. When the component + * AND the view both carry a refused cap, this message names the view and the + * renderer names the component: two declarations, one message each. */ const describeRefusedViewRowLimit = ( view: ElementSavedView | undefined, config: ElementDataSourceConfig | undefined, authored: unknown, ): string | null => { - if (!config || config.limit != null) return null; + if (!config) return null; if (isUsableRowLimit(authored)) return null; - return elementDataSourceRefusedLimitMessage(view, config.view, config.object); + return elementDataSourceRefusedLimitMessage(view, config.view, config.object, config); +}; + +/** + * The loud half for a cap the BINDING declared and the contract refuses + * (objectui#10016), in the core builder's words for that operand. + * + * `@object-ui/core` treats such a cap as not authored: it lets the view's + * usable cap through in its place, or none. Nothing downstream can see the + * refused value any more, so this relay reports it, from an effect, the same + * way it reports the view's refusal. + * + * It needs no condition beyond the caller's (the block reads a row cap at + * all). A usable binding cap is written whatever the component or the view + * carries, so a refused one is always a cap this block did not get. + * + * It can fire beside the other two, and that is one message per declaration, + * not two per mistake: beside {@link describeRefusedViewRowLimit} when the view + * ALSO carries a refused cap, and beside {@link describeDisplacedRowLimit} when + * the component ALSO carries a refused cap and the view's usable one displaced + * it. + */ +const describeRefusedBindingRowLimit = ( + view: ElementSavedView | undefined, + config: ElementDataSourceConfig | undefined, +): string | null => { + if (!config) return null; + return elementDataSourceRefusedLimitMessage(view, config.view, config.object, config, 'binding'); }; const readLimit = (base: Record, key: ElementDataSourceLimitKey): unknown => { @@ -333,17 +367,19 @@ export function useElementDataSourceSchema( schema: S; capMessage: string | null; viewCapMessage: string | null; + bindingCapMessage: string | null; } => { const composed = binding.composed; // BY REFERENCE when there is nothing to apply — a fresh object every render // would remount the block and refetch. The wrapper is memoised alongside it, // so the identity this carries is the one the caller sees. - if (!composed) return { schema, capMessage: null, viewCapMessage: null }; + if (!composed) return { schema, capMessage: null, viewCapMessage: null, bindingCapMessage: null }; const base = (schema ?? {}) as Record; const next: Record = { ...base }; let capMessage: string | null = null; let viewCapMessage: string | null = null; + let bindingCapMessage: string | null = null; if (objectKey !== false) next[objectKey] = composed.object; @@ -371,7 +407,11 @@ export function useElementDataSourceSchema( } if (limit && composed.limit !== undefined) { - const fromView = binding.config?.limit === undefined; + // For the row cap, "declared one" means a cap the contract admits + // (objectui#10016): a refused binding `limit` is not authored, so the + // composer let the view's cap through in its place, and that cap is a + // baseline the component's usable cap still wins over. + const fromView = !isUsableRowLimit(binding.config?.limit); // PRESENCE is not authorship (objectui#9899) — the same question the // `columns` branch above answers by CONTENT, answered the same way here. const authored = readLimit(base, limit); @@ -387,30 +427,35 @@ export function useElementDataSourceSchema( } } - // Outside the branch above on purpose: a refused view cap never reaches - // `composed.limit`, so that branch does not run for it (objectui#10015). + // Outside the branch above on purpose: a refused cap never reaches + // `composed.limit`, so that branch does not run for it (objectui#10015, + // and objectui#10016 for the binding's). if (limit) { viewCapMessage = describeRefusedViewRowLimit(binding.view, binding.config, readLimit(base, limit)); + bindingCapMessage = describeRefusedBindingRowLimit(binding.view, binding.config); } if (viewType && composed.viewType !== undefined && base.viewType === undefined) { next.viewType = composed.viewType; } - return { schema: next as S, capMessage, viewCapMessage }; + return { schema: next as S, capMessage, viewCapMessage, bindingCapMessage }; }, [schema, binding.composed, binding.config, binding.view, objectKey, columns, filter, sort, limit, viewType]); // Keyed on the MESSAGE, so it is one warning per declaration rather than one // per render — and it fires from an effect, never from render, which is the // same shape the renderer sites use for "you declared it, we dropped it". - // One effect per message, so a change to one never re-emits the other. - const { schema: boundSchema, capMessage, viewCapMessage } = mapped; + // One effect per message, so a change to one never re-emits another. + const { schema: boundSchema, capMessage, viewCapMessage, bindingCapMessage } = mapped; React.useEffect(() => { if (capMessage) console.warn(capMessage); }, [capMessage]); React.useEffect(() => { if (viewCapMessage) console.warn(viewCapMessage); }, [viewCapMessage]); + React.useEffect(() => { + if (bindingCapMessage) console.warn(bindingCapMessage); + }, [bindingCapMessage]); return React.useMemo( () => ({ diff --git a/packages/react/src/element-data-source/__tests__/ElementDataSourceGate.test.tsx b/packages/react/src/element-data-source/__tests__/ElementDataSourceGate.test.tsx index 17ebb8f156..1039d7c9a4 100644 --- a/packages/react/src/element-data-source/__tests__/ElementDataSourceGate.test.tsx +++ b/packages/react/src/element-data-source/__tests__/ElementDataSourceGate.test.tsx @@ -813,3 +813,192 @@ describe('ElementDataSourceGate — a saved view’s refused row cap is reported }); }); }); + +/** + * objectui#10016 — the BINDING's own refused row cap, on the renderer path. + * + * Maintainer ruling, option A: a binding `limit` the contract refuses is NOT + * AUTHORED. `@object-ui/core` lets the view's usable cap through in its place, + * and this gate treats that cap as what it is, a view-sourced BASELINE: a + * usable component cap still wins over it. Before the ruling the refused value + * was the binding's, so it was written over everything, the component's usable + * cap included, and the consuming renderer then dropped it and drew its own + * default. + * + * The truth table is binding {absent, usable, refused} × view {no cap, usable, + * refused}, with the component's own cap as a third axis where the ruling moves + * it. Only the rows with a refused binding may change what is written. The + * binding's refusal is reported in the core builder's words for that operand, + * once per declaration, from an effect. + */ +describe('ElementDataSourceGate — a refused binding `limit` is not authored (objectui#10016)', () => { + const warn = () => vi.spyOn(console, 'warn').mockImplementation(() => {}); + const said = (spy: ReturnType) => spy.mock.calls.map((c) => String(c[0])); + afterEach(() => { + vi.restoreAllMocks(); + }); + const viewWith = (cap: Record) => ({ name: 'hot', columns: ['name', 'rating'], ...cap }); + const adapterFor = (view: Record) => makeAdapter({ hot: view }); + const bindingOf = (limit?: unknown) => + ({ object: 'account', view: 'hot', ...(limit === undefined ? {} : { limit }) }); + const bindingSentence = (bad: unknown) => + elementDataSourceRefusedLimitMessage(null, 'hot', 'account', { limit: bad }, 'binding'); + + const VIEW_NO_CAP = viewWith({}); + const VIEW_USABLE = viewWith({ pagination: { pageSize: 7 } }); + const VIEW_REFUSED = viewWith({ pagination: { pageSize: 0 } }); + + /** Resolve one row and let the gate's effects flush. */ + const row = async ( + schema: Record, + view: Record, + mapping: ElementDataSourceMapping = FULL, + ) => { + const spy = warn(); + const result = await resolved(schema, mapping, adapterFor(view)); + await waitFor(() => expect(result.current.status).toBe('resolved')); + return { result, spy }; + }; + + describe('refused binding rows — the ones the ruling moves', () => { + for (const refused of [0, -10, 25.5]) { + it(`binding ${refused} + usable view cap ⇒ the view's cap, and ONE binding warning`, async () => { + const { result, spy } = await row({ type: 'list-view', dataSource: bindingOf(refused) }, VIEW_USABLE); + expect(result.current.schema.pagination).toEqual({ pageSize: 7 }); + await waitFor(() => expect(said(spy)).toEqual([bindingSentence(refused)])); + }); + } + + it('binding 0 + view with no cap ⇒ nothing written (the block’s own default), and ONE binding warning', async () => { + const { result, spy } = await row({ type: 'list-view', dataSource: bindingOf(0) }, VIEW_NO_CAP); + expect(result.current.schema.pagination).toBeUndefined(); + await waitFor(() => expect(said(spy)).toEqual([bindingSentence(0)])); + }); + + it('binding 0 + refused view cap ⇒ nothing written, and one warning per refused operand', async () => { + const { result, spy } = await row({ type: 'list-view', dataSource: bindingOf(0) }, VIEW_REFUSED); + expect(result.current.schema.pagination).toBeUndefined(); + const expected = [ + bindingSentence(0), + elementDataSourceRefusedLimitMessage(VIEW_REFUSED, 'hot', 'account', bindingOf(0)), + ]; + expect(expected[1]).not.toBeNull(); + await waitFor(() => expect([...said(spy)].sort()).toEqual([...expected].sort())); + }); + + it('binding 0 + usable view cap + usable COMPONENT cap ⇒ the component’s cap wins over the view baseline', async () => { + const { result, spy } = await row( + { type: 'list-view', pagination: { pageSize: 50 }, dataSource: bindingOf(0) }, + VIEW_USABLE, + ); + expect(result.current.schema.pagination).toEqual({ pageSize: 50 }); + await waitFor(() => expect(said(spy)).toEqual([bindingSentence(0)])); + }); + + it('binding 0 + view with no cap + usable COMPONENT cap ⇒ the component’s cap stays', async () => { + const { result, spy } = await row( + { type: 'list-view', pagination: { pageSize: 50 }, dataSource: bindingOf(0) }, + VIEW_NO_CAP, + ); + expect(result.current.schema.pagination).toEqual({ pageSize: 50 }); + await waitFor(() => expect(said(spy)).toEqual([bindingSentence(0)])); + }); + + it('binding 0 + usable view cap + refused COMPONENT cap ⇒ the view’s cap, one message per declaration', async () => { + const { result, spy } = await row( + { type: 'list-view', pagination: { pageSize: 0 }, dataSource: bindingOf(0) }, + VIEW_USABLE, + ); + expect(result.current.schema.pagination).toEqual({ pageSize: 7 }); + await waitFor(() => expect(said(spy)).toHaveLength(2)); + expect(said(spy)).toContain(bindingSentence(0)); + // The other one is objectui#10009's, about the COMPONENT's declaration. + expect(said(spy).find((s) => s !== bindingSentence(0))).toContain('pagination.pageSize: 0'); + }); + + it('binding 0 + usable view cap ⇒ the view’s cap on the flat `limit` key too', async () => { + const { result, spy } = await row( + { type: 'object-kanban', dataSource: bindingOf(0) }, + VIEW_USABLE, + { limit: 'limit' }, + ); + expect(result.current.schema.limit).toBe(7); + await waitFor(() => expect(said(spy)).toEqual([bindingSentence(0)])); + }); + + it('does not repeat the binding warning on a re-render of the same declaration', async () => { + const spy = warn(); + const adapter = adapterFor(VIEW_USABLE); + const Block = ({ schema }: { schema: Record }) => ( +
{String((schema.pagination as { pageSize?: number } | undefined)?.pageSize)}
+ ); + const gate = () => ( + + {(boundSchema) => } + + ); + const { getByTestId, rerender } = render(gate()); + await waitFor(() => expect(getByTestId('block').textContent).toBe('7')); + await waitFor(() => expect(said(spy)).toHaveLength(1)); + rerender(gate()); + rerender(gate()); + expect(said(spy)).toEqual([bindingSentence(0)]); + }); + }); + + describe('CONTROLS — rows the ruling does not move', () => { + it('CONTROL — binding 3 + refused view cap ⇒ 3, and NO view warning (the carried defect, silent here already)', async () => { + const { result, spy } = await row({ type: 'list-view', dataSource: bindingOf(3) }, VIEW_REFUSED); + expect(result.current.schema.pagination).toEqual({ pageSize: 3 }); + expect(spy).not.toHaveBeenCalled(); + }); + + it('CONTROL — binding 3 + usable view cap ⇒ 3, and nothing said', async () => { + const { result, spy } = await row({ type: 'list-view', dataSource: bindingOf(3) }, VIEW_USABLE); + expect(result.current.schema.pagination).toEqual({ pageSize: 3 }); + expect(spy).not.toHaveBeenCalled(); + }); + + it('CONTROL — binding 3 + view with no cap ⇒ 3, and nothing said', async () => { + const { result, spy } = await row({ type: 'list-view', dataSource: bindingOf(3) }, VIEW_NO_CAP); + expect(result.current.schema.pagination).toEqual({ pageSize: 3 }); + expect(spy).not.toHaveBeenCalled(); + }); + + it('CONTROL — binding 3 still beats a usable component cap', async () => { + const { result, spy } = await row( + { type: 'list-view', pagination: { pageSize: 50 }, dataSource: bindingOf(3) }, + VIEW_USABLE, + ); + expect(result.current.schema.pagination).toEqual({ pageSize: 3 }); + expect(spy).not.toHaveBeenCalled(); + }); + + it('CONTROL — no binding cap + refused view cap ⇒ nothing written, and the view warning as before', async () => { + const { result, spy } = await row({ type: 'list-view', dataSource: bindingOf() }, VIEW_REFUSED); + expect(result.current.schema.pagination).toBeUndefined(); + await waitFor(() => + expect(said(spy)).toEqual([elementDataSourceRefusedLimitMessage(VIEW_REFUSED, 'hot', 'account')]), + ); + }); + + it('CONTROL — no binding cap + usable view cap ⇒ 7, and nothing said', async () => { + const { result, spy } = await row({ type: 'list-view', dataSource: bindingOf() }, VIEW_USABLE); + expect(result.current.schema.pagination).toEqual({ pageSize: 7 }); + expect(spy).not.toHaveBeenCalled(); + }); + + it('CONTROL — no binding cap + view with no cap ⇒ nothing written, and nothing said', async () => { + const { result, spy } = await row({ type: 'list-view', dataSource: bindingOf() }, VIEW_NO_CAP); + expect(result.current.schema.pagination).toBeUndefined(); + expect(spy).not.toHaveBeenCalled(); + }); + + it('SILENCE — a block that reads no row cap is not told about the binding’s', async () => { + const { result, spy } = await row({ type: 'object-kanban', dataSource: bindingOf(0) }, VIEW_USABLE, { filter: true }); + expect(result.current.schema.limit).toBeUndefined(); + expect(result.current.schema.pagination).toBeUndefined(); + expect(spy).not.toHaveBeenCalled(); + }); + }); +}); From 22feffeb2d0e4e7e360795f52ee6e58fc5b29e6d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 21:17:16 +0000 Subject: [PATCH 2/4] test(react): the gate's carried-defect row is not a CONTROL; its guard is the core builder's Ablating the builder's binding condition reddens it, so by this suite's naming convention it is not a control. Moved into its own block. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC --- .../__tests__/ElementDataSourceGate.test.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/react/src/element-data-source/__tests__/ElementDataSourceGate.test.tsx b/packages/react/src/element-data-source/__tests__/ElementDataSourceGate.test.tsx index 1039d7c9a4..176f9caaad 100644 --- a/packages/react/src/element-data-source/__tests__/ElementDataSourceGate.test.tsx +++ b/packages/react/src/element-data-source/__tests__/ElementDataSourceGate.test.tsx @@ -946,13 +946,18 @@ describe('ElementDataSourceGate — a refused binding `limit` is not authored (o }); }); - describe('CONTROLS — rows the ruling does not move', () => { - it('CONTROL — binding 3 + refused view cap ⇒ 3, and NO view warning (the carried defect, silent here already)', async () => { + describe('the carried defect’s condition, read from the builder on this caller too', () => { + // Not a CONTROL: this gate was already silent here, but the condition that + // keeps it silent now lives in the core builder, shared with + // `ViewDataProvider`, so ablating the builder's condition reddens this row. + it('binding 3 + refused view cap ⇒ 3, and NO view warning', async () => { const { result, spy } = await row({ type: 'list-view', dataSource: bindingOf(3) }, VIEW_REFUSED); expect(result.current.schema.pagination).toEqual({ pageSize: 3 }); expect(spy).not.toHaveBeenCalled(); }); + }); + describe('CONTROLS — rows the ruling does not move', () => { it('CONTROL — binding 3 + usable view cap ⇒ 3, and nothing said', async () => { const { result, spy } = await row({ type: 'list-view', dataSource: bindingOf(3) }, VIEW_USABLE); expect(result.current.schema.pagination).toEqual({ pageSize: 3 }); From 226994a2f8b0c376776e3c3e7d1368c51f32aff8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 21:35:39 +0000 Subject: [PATCH 3/4] chore(changeset): @object-ui/react takes a minor for the gate's changed row-cap precedence (objectui#10016) The gate's observable precedence changes: when the binding cap is refused, a usable component cap now wins over the view cap, and the gate reports a new refusal. Behaviour-changing work is minor in this repo; major is banned. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC --- .changeset/10016-binding-limit-not-authored.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/10016-binding-limit-not-authored.md b/.changeset/10016-binding-limit-not-authored.md index 3f1b33a71b..5e93804838 100644 --- a/.changeset/10016-binding-limit-not-authored.md +++ b/.changeset/10016-binding-limit-not-authored.md @@ -1,6 +1,6 @@ --- '@object-ui/core': minor -'@object-ui/react': patch +'@object-ui/react': minor --- A `dataSource` binding's own `limit` that the contract refuses is now treated as **not From 23001b30b553c1a490df8036d47a81f1ed97e394 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 21:52:55 +0000 Subject: [PATCH 4/4] test(react): pin the three refused-binding gate rows the truth table claimed but no test held (objectui#10016) Component cap 50 + binding 0 + refused view cap; component cap 0 + binding 0 + view with no cap; component cap 0 + binding 0 + refused view cap. Each asserts the written cap and the exact warnings the PR's truth table states. Tests only. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC --- .../__tests__/ElementDataSourceGate.test.tsx | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/react/src/element-data-source/__tests__/ElementDataSourceGate.test.tsx b/packages/react/src/element-data-source/__tests__/ElementDataSourceGate.test.tsx index 176f9caaad..17abf6bcd3 100644 --- a/packages/react/src/element-data-source/__tests__/ElementDataSourceGate.test.tsx +++ b/packages/react/src/element-data-source/__tests__/ElementDataSourceGate.test.tsx @@ -916,6 +916,43 @@ describe('ElementDataSourceGate — a refused binding `limit` is not authored (o expect(said(spy).find((s) => s !== bindingSentence(0))).toContain('pagination.pageSize: 0'); }); + it('binding 0 + refused view cap + usable COMPONENT cap ⇒ the component’s cap, and the binding warning only', async () => { + // The view's refusal changed nothing here: a usable component cap wins + // over any view cap, so the view's sentence would not be true. + const { result, spy } = await row( + { type: 'list-view', pagination: { pageSize: 50 }, dataSource: bindingOf(0) }, + VIEW_REFUSED, + ); + expect(result.current.schema.pagination).toEqual({ pageSize: 50 }); + await waitFor(() => expect(said(spy)).toEqual([bindingSentence(0)])); + }); + + it('binding 0 + view with no cap + refused COMPONENT cap ⇒ the component’s 0 stays, and the binding warning only', async () => { + // Nothing usable to write, so the component's own value stays on the + // node for the renderer's own diagnostic; the gate speaks only for the + // binding. + const { result, spy } = await row( + { type: 'list-view', pagination: { pageSize: 0 }, dataSource: bindingOf(0) }, + VIEW_NO_CAP, + ); + expect(result.current.schema.pagination).toEqual({ pageSize: 0 }); + await waitFor(() => expect(said(spy)).toEqual([bindingSentence(0)])); + }); + + it('binding 0 + refused view cap + refused COMPONENT cap ⇒ the component’s 0 stays, and the view + binding warnings', async () => { + const { result, spy } = await row( + { type: 'list-view', pagination: { pageSize: 0 }, dataSource: bindingOf(0) }, + VIEW_REFUSED, + ); + expect(result.current.schema.pagination).toEqual({ pageSize: 0 }); + const expected = [ + bindingSentence(0), + elementDataSourceRefusedLimitMessage(VIEW_REFUSED, 'hot', 'account', bindingOf(0)), + ]; + expect(expected[1]).not.toBeNull(); + await waitFor(() => expect([...said(spy)].sort()).toEqual([...expected].sort())); + }); + it('binding 0 + usable view cap ⇒ the view’s cap on the flat `limit` key too', async () => { const { result, spy } = await row( { type: 'object-kanban', dataSource: bindingOf(0) },