Skip to content

test(dashboard): measure which analytics starvation path produces the objectui#7696 symptom - #9498

Merged
os-tesla merged 3 commits into
mainfrom
claude/issue-7696-analytics-starvation-path-measurement
Sep 14, 2026
Merged

os-tesla merged 3 commits into
mainfrom
claude/issue-7696-analytics-starvation-path-measurement

Conversation

@os-tesla

Copy link
Copy Markdown
Collaborator

Part of objectui#7696 — the card was re-scoped by triage to a MEASUREMENT, and this is that measurement. It changes no production code: two test-surface files and an empty-frontmatter changeset.

Placeholders below are spelled as UPPERCASE WORDS rather than in angle-bracket form, because this body's bytes are rewritten on save and bracket-shaped spans do not survive.

What was asked, and what came back

The card's original 2x2 claim was falsified in an earlier round and the card was deliberately kept open, because the SYMPTOM was not falsified: a console paints One-off / Recurring / Standing in a pie legend under a Chinese title, description and axis labels, while the neighbouring LIST VIEW of the same field is correct. Triage's question: which of the five starvation paths is live?

All five were driven. The two nominated as likely were not driven alone — the other three were driven as negative controls, so "it is (3)" could actually fail.

The instrument — a four-axis fingerprint

Every one of the five paths turns the chart English, so the chart alone discriminates nothing. What the reporter could see was a combination, and the combination is what was recorded:

axis what it reads
chart the pie categories (jsdom: the chart renderer's props; browser: the PAINTED legend)
list the neighbouring list view's cell for the same field
serverChrome chrome the SERVER resolved — a series label straight off the response's fields[]
bundleChrome an app-bundle-scoped string — the channel the widget title resolves through
metaReads what the chart's metadata channel asked for, and what came back

serverChrome and bundleChrome are separated on purpose. The reporter saw Chinese chrome and the chrome has two independent sources: axis and series labels ride the analytics response (the card measured fields[].label switching with accept-language while rows[] stayed byte-identical), while title and description come out of the app's i18n bundle. A path that kills the bundle kills the second and not the first — and that is exactly what excludes two of the five.

The measured matrix

Read in a real Chromium against the constructed boot (?path= selects the starvation), and identically in jsdom by the pin:

path chart legend neighbouring list serverChrome bundleChrome chart metadata channel
baseline (healthy) 一次性 / 周期性 / 常设 周期性 形式 形式 duly_duty → 200
(1) response omits object One-off / Recurring / Standing 周期性 形式 形式 (none)
(2) metadata read unauthorized One-off / Recurring / Standing 周期性 形式 形式 duly_duty401
(3a) no inline options, list reads the object doc One-off / Recurring / Standing Recurring 形式 形式 duly_duty → 200
(3b) no inline options, list COLUMN declares its own One-off / Recurring / Standing 周期性 形式 形式 duly_duty → 200
(4) app namespace not discoverable One-off / Recurring / Standing Recurring 形式 Form duly_duty → 200
(5) no I18nProvider in scope One-off / Recurring / Standing Recurring 形式 Form duly_duty → 200

The decision

The reported fingerprint admits (1), (2) and (3b). It excludes (3a), (4) and (5).

  • (4) and (5) are excluded by the CHROME, not by the chart. Both kill getAppNamespaces(), which every resolver in useObjectLabel funnels through — so the widget title and description degrade to their authored English along with the legend. The reporter's title and description were Chinese. The server-resolved axis labels stay Chinese in both, which is why reading the axis alone would have cleared them wrongly.
  • (3) splits in two, and the split matters. The chart and the list terminate on the SAME document — GET /api/v1/meta/object/OBJECTNAME — so a document with no inline options starves both, and the list cell falls back to humanizing the stored value (recurringRecurring). That is (3a), and it is not what was reported. (3) only reproduces the report in an install whose list COLUMN declares its own option list, which the grid prefers over the object document — (3b). ⇒ (3) is live for this report only as a property of the VIEW, not of the net.
  • (1), (2) and (3b) are indistinguishable on the rendered page. All three paint an English legend, a correct list and Chinese chrome from both sources. What separates them is the network trace, and only that: (1) issues no metadata read at all, (2) issues one and is refused, (3b) issues one that succeeds and returns a field with no options.

Which one is live in the reporter's install is answered by one reading on their box, not by more code reading: open the dashboard with devtools' network panel and look at GET /api/v1/meta/object/duly_duty — absent ⇒ (1); present and 401/403 ⇒ (2); present and 200 ⇒ inspect fields.form.options in the response body for (3b).

What the constructed boot adds over the pin

jsdom lays out no SVG and paints no legend, so the pin can only read the categories the widget hands the chart renderer; the step from "these are the categories" to "this is what the legend SAYS" is an argument there. The boot mounts the shipped @object-ui/plugin-charts renderer in a real Chromium, so the legend is painted by the chart library and read off the DOM. Both readings agree on all seven rows.

App driven: the constructed boot fixture in this PR (pnpm --dir packages/plugin-dashboard exec vite demo), not the showcase or CRM example app — see Acceptance notes for why.

Evidence

Pin, at 66b794d: pnpm exec vitest run packages/plugin-dashboard/src/__tests__/DatasetWidget.starvationFingerprints-7696.test.tsxTest Files 1 passed (1) / Tests 8 passed (8), exit 0; stable over four separate runs.

A RED reading for every leg. Eight ablations, each removing exactly one starvation (or the production seam), each proven on disk by an anchored count before/after and restored BY STATE (git diff HEAD empty, HEAD blob equal to disk blob), with trap restore EXIT INT TERM and absolute paths throughout:

ablation reddens run output
omitObject: truefalse in the PATH (1) leg PATH (1) Tests 1 failed | 7 passed (8)
chartDoc: nullDUTY PATH (2) Tests 1 failed | 7 passed (8)
DUTY_NO_OPTIONSDUTY in the (3a) leg PATH (3a) Tests 1 failed | 7 passed (8)
listColumnOptions: FORM_OPTIONSundefined PATH (3b) Tests 1 failed | 7 passed (8)
ZH_BUNDLE_UNDISCOVERABLEZH_BUNDLE CONTROL (4) Tests 1 failed | 7 passed (8)
provider: falsetrue CONTROL (5) Tests 1 failed | 7 passed (8)
omitObject: truefalse in the DECISION run list DECISION Tests 1 failed | 7 passed (8)
production seam: drop the translator argument from the dashboard's deriveDimensionLabelMaps call BASELINE Tests 1 failed | 7 passed (8)

The last one is the one that matters most: it proves the pin observes the real net rather than echoing its own fixture. No build was needed for it — the root vitest config aliases every @object-ui/* specifier to that package's src, which its own header states is what makes a separate .dist.spec.tsx project necessary for dist questions.

A race was found and fixed inside the instrument. The first settle was waitFor(three categories exist), which the FIRST paint satisfies — the widget paints the server's rows before the metadata read lands, so a starved path and a healthy one are indistinguishable at that moment. It was caught by a BASELINE that reddened once inside a batched ablation while passing in isolation. Replaced with a fixed number of act-wrapped flush rounds: nothing in the fixture is unbounded (fetch is a mock resolving an already-settled promise), so this is a drain, not a timeout, and it does not tell the instrument the answer. After the fix, the same ablation batch reddens exactly one test per leg.

Affected-package verify at 66b794d: turbo run build --filter=@object-ui/plugin-dashboard... --filter=@object-ui/plugin-charts... --concurrency=2Tasks: 13 successful, 13 total. turbo run type-check --filter=@object-ui/plugin-dashboard --concurrency=2Tasks: 14 successful, 14 total; the new pin IS inside the typechecked program — tsc -p packages/plugin-dashboard/tsconfig.test.json --listFiles names it (measured, not assumed). pnpm exec vitest run --maxWorkers=2 packages/plugin-dashboard/Test Files 119 passed (119) / Tests 1129 passed (1129). Both under scripts/pm/os-verify-lock.sh with OS_VERIFY_LOCK_SLOT=objectui-7696; one queue-timeout (exit 99, 540s) was spent on lock-free gates and re-acquired on the same slot.

Gates (lock-free), all exit 0 at 66b794d on a clean tree: check:changeset-presence, check:control-bytes, check:new-line-citations, check:vi-mock-specifiers, check:vi-mock-inherit, check:vi-mock-override-shape, check:unreferenced-sources, check:phantom-deps, check:unused-deps, check:self-import, check-changeset-no-major. check-governed-queue-guard.mjs --test on all four changed paths: NOT GOVERNED. Control-byte self-scan of the changed files: zero hits, with a positive control containing 0x01 that DID fire.

Declared narrowing of the repo-wide lint. CI runs pnpm lint = turbo run lint = each package's own eslint .; the diff touches one package, so only that package's verdict can move. pnpm --filter @object-ui/plugin-dashboard lint → exit 0, 496 problems (0 errors, 496 warnings); warnings cannot fail because .github/workflows/lint.yml deliberately sets no --max-warnings, as its own header states. The three narrowing readings: (i) the population is eslint's own — a root eslint . --no-inline-config --format json reports it linted 4977 files; (ii) of those, 2 are added by this branch (the changeset .md is not linted); (iii) invariance — eslint.config.js configures no type-aware program (no parserOptions.project, no projectService), and git grep finds zero references to either new file anywhere in the tree, so neither can move any untouched file's verdict. The remaining repo-wide errors are all in packages this branch does not touch (packages/components, app-shell, core, types, cli, data-objectstack, apps/console, layout). Everything else is left to CI.

One fix caught by the package lint and applied: an eslint-disable-next-line no-await-in-loop that the rule never fired for, which reportUnusedDisableDirectives scored as the package's only error.

The census was re-derived from disk, not inherited

Triage recorded on the card that its own localizeFieldOptions call-site list was already incomplete at the very commit it cited. Re-derived at this branch's base 6d5db7b, the non-test call sites are exactly four: the definition in @object-ui/core's chart-series.ts, and one call each in plugin-charts' ObjectChart.tsx, plugin-dashboard's DatasetWidget.tsx and plugin-report's DatasetReportRenderer.tsx (plugin-report's useDatasetDimensionLabels.ts names the seam in prose but does not call it).

This is why the pin lives in one package and still answers for all three. Every one of the five starvations happens ABOVE the three producers, in code all of them share: (1) and (2) starve inside @object-ui/react's useDatasetDimensionMeta, (3) inside @object-ui/core's resolveDimensionFieldMeta — which emits an entry only for a terminal field that actually carries options — and (4) and (5) inside @object-ui/i18n's getAppNamespaces gate. None of them is a property of the dashboard.

Acceptance notes

  • ⚠ A starvation path that arguably should be fixed — reported, NOT fixed, per triage's instruction. (2) degrades silently: an unauthorized or failed metadata read leaves the rows exactly as the server sent them and surfaces no error anywhere, which is deliberate ("best-effort by construction") but is also why (1), (2) and (3b) are indistinguishable on screen and why this card needed a measurement at all. Whether that read should fail loudly is a different deliverable and triage's call to file.
  • The app driven was the constructed boot in this PR, not showcase or CRM. The sibling objectstack checkout in this container has no node_modules (neither at its root nor under examples/app-showcase), so driving a stock example app would mean installing and building a second framework monorepo inside a shared checkout to serve one page. Recorded as a declared departure rather than glossed. Two things follow, and both are honest limits: the showcase's own nearest fixture (showcase_task_metrics, a LOCAL status select dimension charted on the Chart Gallery dashboard, with showcase_task.status.options translated in the zh bundle) was identified but not driven; and showcase_invoice.status — the other local select dimension on a dashboard — carries a zh label but no options block in the shipped bundle, so the Revenue Pulse chart would have starved for a sixth reason (a bundle with no key for those options) that is none of the five. Noted, not filed: it is example-app content, not a defect in the net.
  • Noted, not filed: packages/plugin-dashboard/tsconfig.json includes only src, so the new demo/ directory is outside both the build and the typecheck programs — the same shape packages/plugin-grid/demo already has. Carrier: none today; recorded because a future reader may expect demo/ to be typechecked and it is not.

Generated by Claude Code

…ectui#7696's symptom

Drives all five starvation paths through the real DatasetWidget and records a
four-axis fingerprint per path (chart categories, neighbouring list cell,
server-resolved chrome, app-bundle chrome, metadata-channel trace), plus a
healthy baseline. No production code changes.

Part of objectui#7696

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011QreXiyMEqKLN4U5daMPVa
…tion paths

A `vite demo` page that mounts the shipped DatasetWidget with the real chart
renderer, so the pie legend is PAINTED and can be read off the DOM — the one
step the jsdom pin argues rather than measures. `?path=` selects the starvation.

Declares an empty-frontmatter changeset: nothing published moves.

Part of objectui#7696

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011QreXiyMEqKLN4U5daMPVa
`no-await-in-loop` is not enabled for this file, so the directive was itself the
package's only lint error (`reportUnusedDisableDirectives`).

Part of objectui#7696

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

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 329 chunks) 3062.0 KB 3104.5 KB
Main entry chunk (gzip) 145.7 KB 350 KB
Entry file index-iAJZeJIJ.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) 544.93KB 130.50KB
core (index.js) 8.52KB 3.41KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 213.54KB 59.33KB
fields (index.js) 249.20KB 62.88KB
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) 4.39KB 1.66KB
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.33KB 19.90KB
plugin-chatbot (index.js) 195.34KB 46.51KB
plugin-dashboard (index.js) 131.44KB 34.65KB
plugin-designer (index.js) 215.94KB 44.33KB
plugin-detail (index.js) 252.48KB 65.61KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.71KB 34.16KB
plugin-gantt (index.js) 167.62KB 41.26KB
plugin-grid (index.js) 212.83KB 57.97KB
plugin-kanban (index.js) 46.63KB 14.53KB
plugin-list (index.js) 112.67KB 27.68KB
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.07KB 8.74KB
plugin-tree (index.js) 10.58KB 3.72KB
plugin-view (index.js) 84.36KB 20.78KB
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) 99.04KB 32.62KB
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 (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.66KB 2.50KB
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) 14.82KB 4.99KB
types (ai.js) 0.20KB 0.17KB
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 14, 2026 11:48
@os-tesla
os-tesla added this pull request to the merge queue Sep 14, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 14, 2026
@os-tesla
os-tesla added this pull request to the merge queue Sep 14, 2026
Merged via the queue into main with commit af4ff1d Sep 14, 2026
38 checks passed
@os-tesla
os-tesla deleted the claude/issue-7696-analytics-starvation-path-measurement branch September 14, 2026 12:42
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.

2 participants