Skip to content

refactor(app-shell): one isAiStudioEnabled() accessor for features.aiStudio, replacing two inline spellings - #5645

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-5577-aistudio-accessor
Aug 21, 2026
Merged

refactor(app-shell): one isAiStudioEnabled() accessor for features.aiStudio, replacing two inline spellings#5645
os-sales merged 2 commits into
mainfrom
claude/issue-5577-aistudio-accessor

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes #5577

features.marketplace has a documented accessor whose docblock is where the fail-open
doctrine is written down. features.aiStudio had none — it was read inline at two call
sites, in two different spellings, and neither reader could cite the doctrine.

  • packages/app-shell/src/layout/ChatDock.tsx:263getRuntimeConfig().features.aiStudio !== false (un-chained)
  • packages/app-shell/src/console/home/HomePage.tsx:401getRuntimeConfig().features?.aiStudio !== false (chained)

Both verified on origin/main @ f1c27f037 before any edit; both line numbers as filed.

What changed

  • isAiStudioEnabled() in runtime-config.ts, directly after isMarketplaceEnabled(),
    carrying the same docblock treatment: what the flag means, the "never infer this from
    the shape of a failure" warning, and the fail-open paragraph.
  • Both call sites moved onto it. No inline features.aiStudio read remains at any call
    site (grep for getRuntimeConfig().features in either file returns 0).
  • The four Home suites' module mocks taught the new export. An explicit factory replaces
    the whole module, so an export it does not list is undefined at the call site.
  • New coverage, plus a changeset.

The measurement the card left open: can ChatDock actually receive a partial snapshot?

No — not on any path that exists today. This PR is drift-prevention, not a live bug
fix.
Stated plainly because the answer changes what this PR is, and it was measured
rather than assumed in either direction.

Production: structurally impossible. The accessor and both call sites read the
module's own singleton. Every writer of it constructs features as an object — the
initial { ...defaults }, applyUpdate's { ...current.features, ...(patch.features ?? {}) },
and resetRuntimeConfigForTesting() — and the module exports no setter, so no host
can install a partial snapshot through its API. Driven rather than read off the source:
a new case boots initRuntimeConfig() against a server body carrying no features key
at all
and asserts the snapshot still has one. There is also no bundler alias pointing
runtime-config anywhere else (checked across every vite.config.* / vitest.config.*).

Test harness: no suite reaches it. ChatDockConversation — the only holder of that
read — is module-private and mounts only when ChatDockPanel / ChatDockMobileSheet
get no children override. Every construction in the repo was enumerated:
StudioAiCopilot passes children at both call sites; all 20 renders in ChatDock.test.tsx
pass children (its own header says so); ConsoleLayout is the single consumer that does
not — and it gates the dock behind useAiSurfaceEnabled() and dock.expanded. Of the
six suites in the repo that stand runtime-config in with a factory, none mounts
ConsoleLayout; of the suites that mount ConsoleLayout, none mocks runtime-config.

But the crash shape is real, and it is now measured rather than inferred. Reverting
this call site to the pre-fix un-chained read, with everything else on this branch intact:

TypeError: Cannot read properties of undefined (reading 'aiStudio')
Test Files  1 failed (1)   Tests  4 failed (4)

So the asymmetry the card describes was one house-idiom mock away from the failure that
crashed 29 tests across four suites in PR #5575 before it was corrected there. This PR
closes it at the source, by leaving no inline read to get wrong.

Published-surface reachability (clause ②)

isAiStudioEnabled() is NOT reachable from @object-ui/app-shell's exports map
which is the opposite of what "a sibling next to an existing published accessor" suggests,
so it is worth stating explicitly rather than assuming.

The exports map has exactly one code entry, "."./dist/index.js. src/index.ts
re-exports twelve symbols from ./runtime-config.js — and isMarketplaceEnabled is not
among them
. The new accessor follows its sibling's posture exactly and is likewise not
added there, so the package's published surface is unchanged. Adding it would have been an
unrequested widening of the published API with no consumer pulling on it.

Measured, not just read: dist/index.js and dist/index.d.ts are byte-identical
(same sha256) before and after — see the table below.

Changeset

.changeset/ai-studio-accessor-5577.md, patch (never major — fixed group). The
authority's own verdict line:

✅  9 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/ai-studio-accessor-5577.md.

Both dist legs measured at the real dist/ path, tsconfig.tsbuildinfo cleared between
builds (this package is composite: true and keeps it at packages/app-shell/tsconfig.tsbuildinfo,
i.e. outside dist/, so wiping dist/ alone would not have forced a re-emit).
Compared by sha256, not byte count:

dist/ path before after bytes
runtime-config.js 702c3da611469d2c 7d421f86372a3fee 9125 → 11485
runtime-config.d.ts b63345d08faa9564 7b91fb750487d782 8351 → 10676
layout/ChatDock.js 32be92e2c26a1490 0b06cac3b2f83b07 17432 → 17406
console/home/HomePage.js 221950d741935d82 ca74007cbdaeab5a 30130 → 29661
index.js 56216a9f70bdce5f 56216a9f70bdce5f unchanged
index.d.ts 15cceec868260eee 15cceec868260eee unchanged

Both legs moved at runtime-config, and the entry point did not — the new export exists
in the built artifacts but not on the published entry surface.

Tests

All runs from the repo root (package-cwd vitest is refused, objectui#3378), at
e2d01613a, the final commit.

Superset, derived not sampled. packages/app-shell has 488 test files and its full
suite exceeds the container's foreground cap. The behavioural surface here is exactly two
expressions, so the suites that can change verdict are those that mount HomePage's gate
or ChatDock's default body. The run covers every Home-surface suite, every suite that
mounts ConsoleLayout or imports ChatDock, both remaining runtime-config stand-ins
(PreviewBadge, provisionEnvironment — included to show they are unaffected, not
assumed to be), the marketplace suites that build the same features fixture, the
surfaceAgent consumer of the flag, and the apps/console + i18n + permissions
suites that route through Home. 44 files, 384 tests, all passing.

Test Files  44 passed (44)
      Tests  384 passed (384)

pnpm --filter '@object-ui/app-shell' type-check — clean, 0 errors, after building the
dependency closure first (--filter '@object-ui/app-shell^...' build, 29 projects). The
script name is echoed in the log (> @object-ui/app-shell@17.6.0 type-check), so this is
not a zero-match --filter reporting green having run nothing. Note this package's
type-check is tsc --noEmit && tsc -p tsconfig.test.json, so the new test file is
type-checked too.

Lint. eslint . over packages/app-shell0 errors across the 920 files
eslint selected from its own config
(count read from --format json, not estimated).
All 9 changed files appear in that output with errorCount: 0. The repo-wide
turbo run lint over the other 46 projects is CI's run: no file outside packages/app-shell
changed (the changeset gate independently reports "9 source file(s) of 1 released
package(s)"), and this config is tseslint.configs.recommended with no project /
projectService — linting is per-file and not type-aware, so nothing in this diff can move
an untouched file's verdict.

Gates, exit codes captured before any pipe: check-changeset-presence 0,
check-changeset-no-major 0, check-control-bytes 0, check-package-self-import 0,
check-phantom-dependencies 0, check-i18n-call-site-keys 0, check-node-esm-load --specifiers-only 0.
The two known-broken gauges reproduce their documented failures and name zero files from
this diff: check-eager-closure-budget exits 2 ("No eager-closure report at
apps/console/dist/eager-closure.json … This is a broken gauge, not a passing budget"),
check-doc-snippet-types exits 1 (unbuilt packages/cli, plugin-markdown,
plugin-timeline). Noted, not touched.

Reverse verification — five legs, each scoped to one claim and each measured

Every mutation asserted its anchor's pristine count first, then confirmed on disk that the
injected text was present and the removed text absent, since str.replace returns
happily on zero hits. Restores ran from a trap … EXIT INT TERM, so a foreground-cap
SIGTERM mid-mutation could not leave a mutated tree for a later measurement. No dist/
preflight was owed: the root vitest.config.mts aliases workspace packages to src/, and
every file under mutation is imported by relative path within packages/app-shell/src, so
no built artifact is in the loop for these runs.

leg mutation result
A ChatDock → pre-fix un-chained inline read red, 4/4, TypeError: … (reading 'aiStudio')
B ChatDockchained inline read red, 1/4 — only the accessor case
C one Home mock un-taught the export red, 5/5 in that suite
D accessor !== false=== true green — see below
E parser !== false=== true red, 2/16 in runtime-config.test.ts

Leg B is why the probe has three cases and not two. The two crash-closure cases stay green
for any optional-chained inline read, so on their own they pin the crash and say nothing
about where the doctrine lives — which is the actual subject of this card. The third case
pins the accessor call itself and fails on a chained re-spelling; leg B is the measurement
that it can.

Leg D is a negative result worth recording rather than hiding. Mutating the accessor's
own !== false to === true leaves all 16 cases green, so no test here distinguishes
them. That is not a gap in the tests — it is a property of the module: initRuntimeConfig
already normalises aiStudio to a real boolean on every write (body.features.aiStudio !== false),
and every other writer seeds it from defaults, so current.features.aiStudio is never
undefined and the two spellings cannot disagree through the module's public API. Leg E
mutates that normalisation instead and goes red, which is what confirms the unit tests can
fail at all. The accessor keeps !== false for parity with its sibling and because it
becomes load-bearing the moment either the parser or the snapshot's provenance changes —
but this PR does not claim a test proves it today.

A correction found by ablating rather than trusting

The first version of the new probe wrote vi.mock('../runtime-config', …). From
src/layout/__tests__/ that resolves to src/layout/runtime-config, which does not exist,
and vitest no-ops an unresolvable factory mock instead of erroring — so the partial
snapshot was never installed and the file passed identically against the un-chained pre-fix
read. Leg A caught it: a probe that cannot fail had been sitting green. Corrected to
'../../runtime-config' and re-measured; the leg-A row above is from the corrected probe.


Generated by Claude Code

… two inline spellings

`features.marketplace` had a documented accessor carrying the fail-open doctrine;
`features.aiStudio` was read inline at two call sites in two different spellings —
`ChatDock` un-chained, `HomePage` optional-chained — so neither reader could cite the
doctrine and the un-chained one is a TypeError, not a fail-open, against a snapshot
whose `features` is absent. That is the shape PR #5575 measured crashing 29 tests.

- `isAiStudioEnabled()` sibling of `isMarketplaceEnabled()`, same docblock treatment.
- Both call sites moved onto it; no inline `features.aiStudio` read remains.
- The four Home suites' module mocks taught the new export (an explicit factory
  replaces the whole module, so an unlisted export is `undefined` at the call site).
- New coverage: the accessor's fail-open doctrine, and the dock's default body under
  the partial snapshot the un-chained read could not survive.

Part of #5577
…all the snapshot

Two corrections found by ablating the probe rather than trusting it:

- `vi.mock('../runtime-config')` resolved to `src/layout/runtime-config`, which
  does not exist. Vitest no-ops an unresolvable factory mock instead of erroring,
  so the partial snapshot was never installed and the file was a phantom: it
  passed identically against the un-chained pre-fix read. Corrected to
  `'../../runtime-config'` — measured red on that read afterwards.
- The two crash-closure cases stay green for ANY optional-chained inline read, so
  they pin the crash and say nothing about where the doctrine lives. Added a case
  that pins the accessor call itself, scoped to exactly that and measured to fail
  on a chained inline re-spelling.

Part of #5577
@github-actions github-actions Bot added the tests label Aug 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 52 chunks) 3785.8 KB 3867.2 KB
Main entry chunk (gzip) 151.6 KB 350 KB
Entry file index-CftgFQXz.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 (index.js) 10.04KB 3.72KB
app-shell (runtime-config.js) 11.22KB 3.78KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 29.34KB 7.05KB
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) 6.35KB 2.43KB
auth (index.js) 2.77KB 1.22KB
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.02KB 0.89KB
auth (useIsWorkspaceAdmin.js) 3.04KB 1.45KB
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) 507.00KB 113.72KB
core (index.js) 4.51KB 1.80KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 159.80KB 44.33KB
fields (index.js) 238.85KB 60.13KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.44KB 1.39KB
i18n (pickLocalized.js) 7.22KB 3.08KB
i18n (provider.js) 23.13KB 7.63KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 33.40KB 8.71KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
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.35KB 3.31KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.42KB 1.42KB
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.81KB 0.83KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.62KB 12.83KB
plugin-charts (index.js) 64.65KB 18.32KB
plugin-chatbot (index.js) 181.41KB 43.22KB
plugin-dashboard (index.js) 128.33KB 32.93KB
plugin-designer (index.js) 212.30KB 42.80KB
plugin-detail (index.js) 242.16KB 60.90KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 125.07KB 30.43KB
plugin-gantt (index.js) 164.10KB 39.87KB
plugin-grid (index.js) 200.79KB 54.26KB
plugin-kanban (index.js) 52.93KB 14.60KB
plugin-list (index.js) 111.70KB 27.17KB
plugin-map (index.js) 20.06KB 6.62KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.49KB 11.93KB
plugin-timeline (index.js) 26.68KB 7.66KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.50KB 20.68KB
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) 3.77KB 1.33KB
react (SchemaRenderer.js) 43.66KB 14.77KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.33KB 0.69KB
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 (index.js) 4.77KB 2.16KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 10.76KB 3.17KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 6.92KB 2.40KB
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) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 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-retry.js) 4.32KB 2.02KB
types (index.js) 3.08KB 1.53KB
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 (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
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

Sign up for free to 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.

features.aiStudio is read inline at two call sites while features.marketplace has an accessor — one fail-open doctrine, two spellings

1 participant