Skip to content

fix(plugin-kanban): gate ObjectKanban's record query on the object definition - #6420

Merged
os-support-ai merged 2 commits into
mainfrom
claude/issue-6271-kanban-double-fetch
Aug 25, 2026
Merged

fix(plugin-kanban): gate ObjectKanban's record query on the object definition#6420
os-support-ai merged 2 commits into
mainfrom
claude/issue-6271-kanban-double-fetch

Conversation

@claude

@claude claude Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Fixes #6271

ObjectKanban ran its fetch effect twice on every standalone mount: objectDef sat in that effect's dependency list while a separate effect resolved it, so the first query went out before the definition landed — buildExpandFields saw no fields, and the query carried no $expand at all — and a second, expanded one followed.

The definition now gates the query instead of refining it afterwards. One query per mount, expanded the first time.

The measurement the card was gated on

Triage named one deciding reading — how long schema resolution takes on a cold boot — and forbade picking an arm before taking it. Three measurements, all against real code.

M1 — real ObjectStackAdapter, real MetadataCache, real loopback HTTP. connect() + the metadata GET, at two transport latencies:

latency 0ms latency 25ms
cold getObjectSchema (connect + meta GET) 27.85ms 61.66ms
of which the shared discovery/connect leg 3.21ms 27.85ms
the metadata GET alone, already connected 2.52ms 27.38ms
warm (MetadataCache hit, n=20 avg) 0.01ms 0.00ms
coalesced child (parent's request in flight) 4.04ms 56.70ms
HTTP requests for 22 reads of one object 2 (1 discovery + 1 metadata) 2

So a schema read is one small GET behind the same discovery call find already awaits, and every reader after the first — inside a 5-minute TTL — pays 0.01ms and no request. Concurrent readers coalesce onto one in-flight request rather than adding a second.

M2 — the mount timeline of the real component, instrumented adapter, DOM polled every 2ms, per-query payloads made distinguishable so the DOM says which round trip is on screen. Before the change:

schema/find (ms) queries first query's result ever painted? fully populated board
30 / 30 2 never 156.9ms
30 / 60 2 never 119.8ms
5 / 30 2 never 54.7ms
100 / 10 2 yes, t+23.7ms — with RAW lane titles 143.4ms
200 / 20 2 yes, t+34.1ms — with RAW lane titles 241.2ms

⭐ The finding that decided it: wherever the schema resolves no slower than the row query, the first response never reaches the screen. The definition lands, the effect re-runs, its cleanup flips isMounted false, and the unexpanded rows are discarded on arrival. That round trip bought no earlier paint — it bought a query whose answer was thrown away. The card's ⚠️ framed this as "a redundant request traded against a slower first paint"; in the measured regimes there was no trade to make.

The two regimes where it did paint early need the metadata GET to be 5–10x slower than a 100-row expanded query, and what they paint is not a graceful partial board: with no columns authored, lanes are derived from the DATA's distinct values, so the board shows one raw-valued lane (qualified) and re-lanes wholesale ~100–190ms later when the declared options arrive.

M2 again, after the change — same harness, same profiles:

schema/find (ms) queries fully populated board (before → after)
30 / 30 1 156.9 → 145.2ms
30 / 60 1 119.8 → 110.6ms
5 / 30 1 54.7 → 52.4ms
100 / 10 1 143.4 → 132.6ms
200 / 20 1 241.2 → 247.1ms

Faster in four of five profiles (one less query competing, and nothing left to overwrite); +5.9ms in the pathological one, where the early paint it gives up was a raw-lane board.

M3 — the hosted paths. Rendering object-view and list-view with a kanban view: both hosts read the definition on the same adapter (2 getObjectSchema calls, 1.1ms apart under object-view), which on the real adapter is one request with the second reader coalesced or warm. Both also hand the board its rows via data, so the board's internal fetch — this effect — never runs there at all. The double fetch is specific to the standalone board, which is exactly the mount the card measured.

Verdict: schema resolution is fast and shared ⇒ gate the first fetch (triage's arm A).

What changed

  • The definition read and the fact that it has settled are one state, keyed by the object it belongs to, so they cannot disagree for a commit, and so switching objects closes the gate in the same commit that changes it rather than one commit later.
  • ⚠️ The gate is on the read having settled, not on objectDef being truthy. Those differ for exactly the boards least able to report it: an adapter that exposes no getObjectSchema, and a read that throws. Both now settle with nothing to report, and the board falls through to an unexpanded query — under a truthy-value gate both would wait forever and render empty, with no error and no request.
  • Every exit of the metadata effect settles the resolution, because the query waits on it.
  • Boards fed rows by a parent (data, bind, inline schema.data) are untouched: they never ran this effect, and they still read the definition for lane titles and labels.

Ghost-assertion guard — both directions, ablated

Ablation A — the pin against unmodified origin/main. Nothing mutated but ObjectKanban.tsx, restored to the base blob 2c99db8 (verified: disk hash equals the base blob; marker count objectDefReady 5 → 0, setObjectDef 0 → 2). Predicted RED on the two discriminators. Observed 3 failed | 2 passed:

AssertionError: expected [ { '$filter': undefined, …(1) } ] to deeply equal []
+   { "$filter": undefined, "$top": 100 },          ← the unexpanded first query

AssertionError: expected [ 'schema:issued', 'find', …(1) ] to deeply equal [ … ]
-   "schema:settled",
    "find",
+   "schema:settled",                                ← queried before the read settled

One failure past the prediction, reported as observed: the rejection-path test also carries an ordering assertion, which is a discriminator rather than a control — its control half (the board still queries, once, and paints) passed on main. Restored by hash afterwards: git diff HEAD empty, disk hash equals the HEAD blob.

Ablation B — proving the controls are not decoration. The guard's rule is that a query-count assertion which would pass with ZERO fetches is not a pin. So the mis-fix was implemented: if (!objectDefReady)if (!objectDef) (mutation confirmed on disk — marker counts 1 → 0 and 0 → 1, file hash differs from the HEAD blob, and the run aborts if it does not). Predicted: the two no-definition controls go red, the rest stay green. Observed exactly that — 2 failed | 3 passed, both failures being expected 'Open0…' to contain 'Q3 renewal' after a 1020ms waitFor timeout: the board never fetched at all. Restored inside an EXIT/INT/TERM trap with absolute paths; verified by hash, git diff HEAD empty.

The coupling, recorded in the code

Triage asked that whichever arm lands notes the coupling beside the isOpaqueId suppression, not only here. It does — a block above OPAQUE_ID_RE says what changed and what did not. Half of what that predicate hid was this fetch ordering, and that half is gone. It is not thereby redundant: unexpanded rows still reach the card path from sources the gate does not sit in front of, and the first of those is measured rather than assumed — ObjectView hosts this board and its own query goes out as { $top: 100 }, so every card in that path is built from raw ids (see the finding below). Author-supplied bind/inline rows and backends that decline an expansion are the others.

Verification

All on 88e10b836 (the final commit), each command's own verdict line quoted:

  • pnpm exec vitest run packages/plugin-kanban/Test Files 18 passed (18) / Tests 103 passed (103)
  • pnpm --filter '@object-ui/plugin-kanban' run type-check (tsc --noEmit && tsc -p tsconfig.test.json) → clean, after building the dependency closure (pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-kanban^...' build; without it the run reports 40 phantom TS2307s from missing dist/*.d.ts). Both edited test files confirmed present in the program via --listFiles, so that green covers them.
  • Host-path consumers, targeted: ObjectView.kanbanConditionalFormatting, ObjectView.tableColumnsForwarding, ListView.test, ElementDataSourceGate, react-page-state, react-page-scopeTest Files 6 passed (6) / Tests 206 passed (206)
  • eslint . in packages/plugin-kanban (the exact unit turbo run lint runs for it) → 34 files collected, 0 errors. ObjectKanban.tsx's warning profile is byte-identical to base: 33 warnings, same five rule ids, measured by linting the base blob through --stdin-filename.
  • node scripts/check-control-bytes.mjsOK (scanned 5306 tracked text file(s)); check-vi-mock-specifiers, check-package-self-import, check-phantom-dependencies, check-changeset-no-major, check-changeset-presence, check-changeset-fixed → all .

Lint narrowing, declared: the repo-wide scan is CI's. What ran here is this package's complete lint unit; the population and the 34-file count come from eslint's own config resolution (--format json), and eslint.config.js declares no parserOptions.project / projectService — zero hits — so no rule reads cross-file type information and a diff confined to this package cannot move any untouched file's verdict.

One fixture moved with the fix

expandableFamily.identity-5874.test.tsx synchronised on the defect: its awaitSchemaFetch waited for a second find call, because only the second carried the expand set. Same condition, re-spelled against the ordering that now holds (one call, issued after the definition settles), with a comment recording why one call is sufficient and what re-opens the vacuum if the gate is ever removed. Its assertions are unchanged.

Out of scope — filed, not fixed


Generated by Claude Code

claude added 2 commits August 25, 2026 22:44
…finition

The board ran its fetch effect twice on every standalone mount: `objectDef`
sat in that effect's dependency list while a separate effect resolved it, so
the first query went out before the definition landed and carried no `$expand`
at all, and a second, expanded one followed.

The definition now gates the query rather than refining it afterwards — one
query per mount, expanded the first time. The gate is on the read having
SETTLED, not on the definition being truthy: an adapter with no
`getObjectSchema`, and a read that throws, settle with nothing to report and
the board falls through to an unexpanded query instead of waiting forever.

Chosen on measurement (recorded in the PR body and the changeset): the first
response never reached the screen in the regimes that matter, and a schema read
is one small cached GET behind the discovery call `find` already awaits.

Part of #6271

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q
…ble fetch

`awaitSchemaFetch` waited for a SECOND `find` call, because only the second
carried the expand set — a synchronisation built on the defect #6271 removes.
Same condition, spelled against the ordering that now holds: one call, issued
after the definition settles. The comment records why one call is sufficient
and what re-opens the vacuum if the gate is ever removed.

Also drops a `.apply()` in the new pin's adapter stub (`prefer-spread`).

Part of #6271

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

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 52 chunks) 3233.6 KB 3266.6 KB
Main entry chunk (gzip) 157.4 KB 350 KB
Entry file index-c51lcR-f.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.30KB 4.28KB
app-shell (runtime-config.js) 18.10KB 6.51KB
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) 505.86KB 114.58KB
core (index.js) 5.30KB 2.13KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 173.18KB 47.97KB
fields (index.js) 238.89KB 60.02KB
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.66KB 12.84KB
plugin-charts (index.js) 64.66KB 18.32KB
plugin-chatbot (index.js) 188.60KB 44.82KB
plugin-dashboard (index.js) 133.46KB 34.48KB
plugin-designer (index.js) 211.90KB 42.74KB
plugin-detail (index.js) 245.10KB 62.31KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 131.78KB 32.19KB
plugin-gantt (index.js) 164.14KB 39.87KB
plugin-grid (index.js) 201.79KB 54.60KB
plugin-kanban (index.js) 53.16KB 14.65KB
plugin-list (index.js) 112.63KB 27.45KB
plugin-map (index.js) 20.09KB 6.62KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.49KB 11.93KB
plugin-timeline (index.js) 26.70KB 7.69KB
plugin-tree (index.js) 9.26KB 3.13KB
plugin-view (index.js) 84.55KB 20.74KB
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) 54.84KB 18.43KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.35KB 0.70KB
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) 12.13KB 3.65KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 7.54KB 2.63KB
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

@os-support-ai
os-support-ai marked this pull request as ready for review August 25, 2026 23:42
@os-support-ai
os-support-ai added this pull request to the merge queue Aug 25, 2026
Merged via the queue into main with commit 7d2a689 Aug 25, 2026
29 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-6271-kanban-double-fetch branch August 25, 2026 23:54
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(plugin-kanban): ObjectKanban fetches TWICE on mount — the first query runs before the object schema resolves, so it carries no $expand

2 participants