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()anddock.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. outsidedist/, so wiping dist/ alone would not have forced a re-emit).
Compared by sha256, not byte count:

dist/ pathbeforeafterbytes
runtime-config.js702c3da611469d2c7d421f86372a3fee9125 → 11485
runtime-config.d.tsb63345d08faa95647b91fb750487d7828351 → 10676
layout/ChatDock.js32be92e2c26a14900b06cac3b2f83b0717432 → 17406
console/home/HomePage.js221950d741935d82ca74007cbdaeab5a30130 → 29661
index.js56216a9f70bdce5f56216a9f70bdce5funchanged
index.d.ts15cceec868260eee15cceec868260eeeunchanged

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.

legmutationresult
AChatDock → pre-fix un-chained inline readred, 4/4, TypeError: … (reading 'aiStudio')
BChatDockchained inline readred, 1/4 — only the accessor case
Cone Home mock un-taught the exportred, 5/5 in that suite
Daccessor !== false=== truegreen — see below
Eparser !== false=== truered, 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

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3785.8 KB3867.2 KB
Main entry chunk (gzip)151.6 KB350 KB
Entry fileindex-CftgFQXz.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 (index.js)10.04KB3.72KB
app-shell (runtime-config.js)11.22KB3.78KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)29.34KB7.05KB
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)6.35KB2.43KB
auth (index.js)2.77KB1.22KB
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.02KB0.89KB
auth (useIsWorkspaceAdmin.js)3.04KB1.45KB
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)507.00KB113.72KB
core (index.js)4.51KB1.80KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)159.80KB44.33KB
fields (index.js)238.85KB60.13KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.22KB3.08KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
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.35KB3.31KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.42KB1.42KB
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.81KB0.83KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.65KB18.32KB
plugin-chatbot (index.js)181.41KB43.22KB
plugin-dashboard (index.js)128.33KB32.93KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)242.16KB60.90KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.07KB30.43KB
plugin-gantt (index.js)164.10KB39.87KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.70KB27.17KB
plugin-map (index.js)20.06KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.50KB20.68KB
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)3.77KB1.33KB
react (SchemaRenderer.js)43.66KB14.77KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.33KB0.69KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (index.js)4.77KB2.16KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)10.76KB3.17KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)6.92KB2.40KB
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)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.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-retry.js)4.32KB2.02KB
types (index.js)3.08KB1.53KB
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 (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
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

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.

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

1 participant

@os-sales