Skip to content

fix(plugin-view): gate ObjectView's non-grid query on the object schema - #6462

Merged
os-support-ai merged 2 commits into
mainfrom
claude/issue-6419-objectview-expand-gate
Aug 26, 2026
Merged

fix(plugin-view): gate ObjectView's non-grid query on the object schema#6462
os-support-ai merged 2 commits into
mainfrom
claude/issue-6419-objectview-expand-gate

Conversation

@claude

@claudeclaudeBot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Fixes#6419

ObjectView's non-grid fetch effect built its expand set from objectSchemaRef.current — a ref assigned in the render body, deliberately kept out of the effect's dependency list so the effect would run exactly once per mount. On that one run the ref was still null, so buildExpandFields returned [] and the query went out as { $top: 100 } with no $expand at all; because the effect never re-ran on the schema's arrival, it never went out with one either.

ObjectView hands the rows it fetches to the child view as data={data}, which suppresses that child's own fetch. So every lookup / master_detail / user / tree field in the six non-grid views it hosts — kanban, calendar, gallery, timeline, gantt, map — rendered from raw foreign-key ids.

The object schema and the fact that its read has settled are now one piece of state, keyed by object name, and the record query waits on it. The gate is on the read having settled, not on a truthy schema: a view whose adapter exposes no getObjectSchema, or whose read threw, still queries (unexpanded) rather than waiting forever, and switching objects closes the gate in the same commit rather than sending the previous object's expand set.

The measurement — taken on THIS effect, not inherited

The charter required measuring what an extra re-run costs here before adopting #6271's gate shape, because this effect has five more dependencies than the kanban's (currentViewType, currentNamedViewConfig, activeView, renderListView, refreshKey). Instrumented adapter, getObjectSchema and find both resolving in 30 ms, four host regimes (bare; named listViews; views prop; views prop with a re-rendering parent). Deliveries are the non-empty row arrays handed to the child, tagged with the query that produced them:

find callsparamschild deliveries
before (origin/main)1{$top:100}$expandnever1 -> ["raw"]
objectSchema added to the deps2[{$top:100}, {$top:100,$expand:[...]}]2 -> ["raw","expanded"]
gated (this PR)1{$top:100,$expand:["owner","account"]}1 -> ["expanded"]

The middle row is where this component parts company with the board, and it is why the kanban's numbers could not decide this. On ObjectKanban the unexpanded first response was discarded on arrivalisMounted flipped false before it landed — so an extra re-run bought a wasted round trip and no visible artefact. Here the measured order is schema:settled -> find:settled -> find:issued: the raw rows settle into setDatabefore the re-run's cleanup, reach the child, and paint. An extra re-run on this effect therefore costs a visible two-step render — every relation field blank (kanban's isOpaqueId) or a raw id for ~40 ms, then swapping — which is exactly the "duplicate events in child views like the calendar" the removed ref-comment cited. So the measurement supports the gate more strongly here than on the board, for a different reason.

What the gate costs is one schema resolution ahead of the query, and this component already issues that read unconditionally on mount — measured getObjectSchema calls = 1 in every regime, before and after. Correct, expanded rows land at the same wall clock either way: 66.8-69.1 ms gated vs 68.3-69.3 ms via the dependency list, with half the queries and no wrong paint in between.

Two further readings worth recording. The five extra dependencies do not amplify the gate: in the churning regime the gate lowered the query count (4 -> 3) because every run now happens after the resolution settles. And that same regime is the only way origin/main ever sends an $expand — by the luck of an unrelated parent re-render after the schema landed, not by design.

Reverse verification

The pin was run against unmodified origin/main (ObjectView.tsx restored from origin/main, confirmed on disk by blob hash 23c0163f2b6c3c56e514a5f8b4d4f1f00e7365c3, gate line count 0 / ref line count 1). Predicted red; observed red — 6 of 8 failed:

Test Files 1 failed (1)
Tests 6 failed | 2 passed (8)
expected [] to have a length of 1 but got +0 <- no expanded call, ever
expected [ 'schema:issued', 'find' ] to deeply equal [ 'schema:issued', ...(2) ] <- queried before the read settled
expected 'raw' to be 'expanded' <- the child received raw rows

Against this branch: Test Files 1 passed (1) / Tests 8 passed (8). Restored by hash, not by exit code — on-disk hash back to 0ed68e6a514739a481c9ecf5d46da364d7803726 with git diff HEAD empty for the path.

The two tests that pass in both directions are the ones guarding the truthy-gate trap (adapter with no getObjectSchema; grid path still delegating). They cannot discriminate against origin/main, which has no gate at all — they exist so a future truthy-value gate goes red.

Ghost-assertion guard. A query count, or an $expand presence check, would also pass if the view stopped fetching altogether. So every count is reached only after waiting for a real call; the first test's waitFor targets the expanded call specifically, so zero fetches times out rather than reading as success; one test asserts rows actually reach the child; and $expand is asserted against the expandable fields derived from the fixture schema through core's own EXPANDABLE_FIELD_TYPES (all four relation types), in both directions — the four expandable names must be present and the three plain ones absent.

Verification

All readings below are from the gate union re-run on the final commit, eb329847b, and each quotes the gate's own verdict line rather than a shell $? read through a pipe.

  • pnpm exec vitest run packages/plugin-view/ -> Test Files 24 passed (24) / Tests 231 passed (231)
  • pnpm exec vitest run packages/app-shell/src/views/ (app-shell consumes @object-ui/plugin-view) -> Test Files 324 passed (324) / Tests 3085 passed | 1 skipped (3086)
  • pnpm --filter @object-ui/plugin-view type-check -> exit 0, script echoed as tsc --noEmit && tsc -p tsconfig.test.json. The dependency closure was built first (pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-view^...' build); without it a fresh worktree reports Cannot find module '@object-ui/core', which is missing dist/*.d.ts, not a type error. Confirmed the new pin file is genuinely in the program rather than excluded: tsc -p tsconfig.test.json --listFiles lists ObjectView.expandGate.test.tsx and ObjectView.tsx.
  • node scripts/check-changeset-presence.mjs -> 2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
  • node scripts/check-changeset-no-major.mjs -> No changeset declares a major bump.
  • node scripts/check-control-bytes.mjs -> OK (scanned 5323 tracked text file(s); skipped 85 binary), plus a direct grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' over the changed files: clean.
  • node scripts/check-vi-mock-specifiers.mjs -> OK (the new test carries vi.mock calls)
  • node scripts/check-phantom-dependencies.mjs, node scripts/check-package-self-import.mjs -> both OK

Declared lint narrowing. Repo-wide turbo run lint was not run locally; eslint . --no-inline-config was run over the whole of packages/plugin-view — the complete CI lint unit for the only package this diff changes — giving 0 errors, 265 warnings across 39 files, a count read from eslint's own --format json output, over the file set eslint's own config selected rather than a set I chose. Nothing outside that package can shift: eslint.config.js declares no parserOptions.project / projectService, so linting here is not type-aware and this diff cannot move the verdict on a file it does not touch. CI runs the full farm regardless.

Warning delta on the edited file, measured before/after against origin/main's blob: 60 -> 59. The one that disappears is react-hooks/refs, the render-body ref write this removes. react-hooks/exhaustive-deps is unchanged at 3, so dropping the now-false eslint-disable-next-line react-hooks/exhaustive-deps added nothing — objectSchemaReady and objectSchema are both listed honestly, and both are load bearing: objectSchema is null in two different situations (before the read settles, and after it settles with nothing) and only the first may hold the query.

Notes

Draft, per the dispatch: the PM lands it.

Generated by Claude Code


Generated by Claude Code

The non-grid fetch effect built its expand set from `objectSchemaRef.current`,
a ref assigned in the render body, and deliberately omitted `objectSchema` from
its dependency list. That bought one effect run per mount and paid for it with
the expansion, permanently: on that one run the ref was still null,
`buildExpandFields` saw no fields, and the query went out as `{ $top: 100 }`
with no `$expand` at all — and the effect never re-ran on the schema's arrival.
ObjectView hands those rows to the child as `data={data}`, suppressing the
child's own fetch, so every lookup / master_detail / user / tree field in the
six non-grid views it hosts (kanban, calendar, gallery, timeline, gantt, map)
rendered from raw foreign-key ids.
The schema read and the fact that it has SETTLED are now one piece of state,
keyed by object name, and the record query waits on it. The gate is on the read
having settled, not on a truthy schema: a view whose adapter exposes no
`getObjectSchema`, or whose read threw, still queries — unexpanded — rather
than waiting forever.
Measured on this effect rather than inherited from the kanban's, because it has
five more dependencies. Instrumented adapter, schema/find both 30ms, four host
regimes: before, 1 find with no `$expand` ever and one raw delivery to the
child; with `objectSchema` in the deps, 2 finds and TWO deliveries (`raw` then
`expanded`) — a visible two-step paint, since the raw rows settle before the
re-run's cleanup here, unlike on the kanban where they were discarded; gated,
1 find carrying `$expand` the first time and one expanded delivery, with
correct rows landing at the same wall clock as the dependency version.
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

MetricValueBudget
Eager closure (gzip, 52 chunks)3233.8 KB3266.6 KB
Main entry chunk (gzip)157.4 KB350 KB
Entry fileindex-J4-eIBly.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.30KB4.28KB
app-shell (runtime-config.js)18.10KB6.51KB
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)505.99KB114.64KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.10KB47.96KB
fields (index.js)238.89KB60.02KB
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.66KB12.84KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.60KB44.82KB
plugin-dashboard (index.js)133.48KB34.49KB
plugin-designer (index.js)211.90KB42.74KB
plugin-detail (index.js)245.10KB62.31KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)131.78KB32.19KB
plugin-gantt (index.js)164.14KB39.87KB
plugin-grid (index.js)201.79KB54.60KB
plugin-kanban (index.js)53.16KB14.65KB
plugin-list (index.js)112.63KB27.45KB
plugin-map (index.js)20.09KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)26.72KB7.71KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)84.82KB20.79KB
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)56.69KB19.03KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)2.05KB1.04KB
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)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
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

@os-support-ai
os-support-ai marked this pull request as ready for review August 26, 2026 01:43
@os-support-ai
os-support-ai added this pull request to the merge queueAug 26, 2026
Merged via the queue into main with commit e929c56Aug 26, 2026
30 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-6419-objectview-expand-gate branch August 26, 2026 01:54
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.

finding(plugin-view): ObjectView's non-grid fetch NEVER injects $expand — the ref it reads the schema from is empty on the one run it makes

2 participants

@os-support-ai@claude