fix(app-shell): scope the Interfaces block selection to its leaf - #7145

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7137-selection-leak-non-editable-leaf
Sep 1, 2026
Merged

fix(app-shell): scope the Interfaces block selection to its leaf#7145
os-warren merged 1 commit into
mainfrom
claude/issue-7137-selection-leak-non-editable-leaf

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7137

The Interfaces pillar's block selection outlived a leaf change on every leaf
where isEditable === false, because the only clear sat after the draft-load
effect's early return:

React.useEffect(() => {
if (!current || !isEditable) { setDraft({}); setHasDraft(false); return; }
...
setSelection(null); // never reached on the early-return path
}, [client, current, isEditable, publishNonce]);

isEditable = !!Preview && !StudioCanvas is a conjunction, so that early return
covers two disjoint populations, and the surviving selection then described a
block on the previous leaf's canvas.

Premise re-derived on the current head

Verified in a fresh worktree at origin/main = 0d4c7890d — the commit that
landed #7121, i.e. after the card was filed. The guard's shape and the stranded
clear are both still exactly as the card quotes them. Premise holds.

Populations, measured

populationin-repo countlive symptoms after #7121
(a) studio-canvas leaves1 (object, studio-canvas-preview.tsx:97)folded-layout center tab only
(b) leaf whose own type has no designer0 under the shipped registrationscoped inspector + clear button

The pillar's leaves come from resolveSurface, which can produce exactly five
types (page / object / dashboard / report / action), and
registerBuiltinPreviews() registers a preview for all five. So population (b)
is empty when register-builtins has run. It is reachable only in a host
that registers a partial set — which is the state
StudioDesignSurface.designerRegistryPartial.test.tsx already treats as a
supported product fact, and which this PR's pin constructs.

On population (a) the rail and header symptoms are already unreachable, because
#7121 gates both on StudioCanvas. What that card did not gate is
hasInspectorTarget, which feeds nextCenterTab — so the folded center tab is
the one surviving observable there, and it is measured below.

The repair, chosen by measurement

The selection is now stamped with the leaf it was made on and read back through
that key — the same shape blockingReport already uses against inspectorKey
in this component.

The card offered three candidates. Candidate 1, hoisting setSelection(null)
above the guard, was implemented and measured rather than reasoned about: it
closes the center-tab symptom, but the clear runs in an effect, i.e. one
committed render after the leaf change, so the foreign-block inspector still
mounts. Against the same pin:

  • unfixed tree: ledger [ 'page:home_page:block:blk_1', ...(2) ] — 3 stale renders
  • candidate 1 (hoist): ledger [ 'page:home_page:block:blk_1' ]1 stale render, 1 failed | 4 passed (5)
  • this PR (leaf-keyed): ledger []5 passed (5)

That single surviving render is what decided it. Keying also leaves no
imperative clear for a future guard to strand, which is how this defect arose.

Within a leaf nothing changes: the Design/Run round trip keeps its selection
(#5800's stated contract), and a same-leaf reload via publishNonce still
clears it — both pinned.

Both symptoms are closed, and the card's NOT MEASURED sub-claim is now measured

The card flagged the centerTab symptom as "read from the code path, not
measured in a browser."
It is measured here, in the folded layout with
foldInspector, reading the active tab from the DOM:

  • control on the same instrument: picking a block does drive the tab to
    Properties, so the reading below is of a live auto-switch, not a static strip;
  • unfixed: expected 'Properties' to be 'Canvas' — the stale target suppresses
    the return-to-Canvas edge;
  • fixed: the tab returns to Canvas.

One refinement to the card's wording: the mechanism is not an auto-switch to
Properties on arrival. hasInspectorTarget never changes across the leaf walk,
so nextCenterTab sees no edge at all and the author is stranded on the
Properties tab they were already on. Same defect, same repair, more precise
cause.

Not done here

Verification

All runs below are at db22d18b0, on a clean tree.

  • pnpm exec vitest run .../StudioDesignSurface.selectionLeafScope.test.tsx
    new pin. Before: 3 failed | 2 passed (5). After: 5 passed (5).
    The 2 that passed before are named and explained in the file: one is the
    over-fire fence (green on both sides by design), the other is the
    studio-canvas leaf, green because finding(app-shell): the Interfaces pillar offers Design mode and a "click a block" rail on leaves that have no block canvas — measured with a POPULATED registry #7121 already blocks that path — the
    finding that made the center-tab test the real measurement for population (a).
  • pnpm exec vitest run packages/app-shell/src/views/studio-design/
    43 passed (43) files, 230 passed (230) tests.
  • pnpm exec vitest run packages/app-shell/src/views/metadata-admin/
    221 passed (221) files, 2303 passed | 1 skipped (2304).
  • pnpm --filter '@object-ui/app-shell' type-check — exit 0
    (tsc --noEmit && tsc -p tsconfig.test.json). Confirmed to actually cover the
    new file: --listFiles lists it, alongside a known-covered sibling as control.
  • pnpm --filter '@object-ui/app-shell' lint — exit 0, 0 errors
    (2853 pre-existing warnings package-wide, none in the changed files).
  • check:control-bytes, check:vi-mock-specifiers, check:vi-mock-inherit,
    check:i18n-keys, check:shell-escape-residue — all exit 0.
  • scripts/check-changeset-no-major.mjsNo changeset declares a major bump.

The ablation restore was verified by state, not by the trap firing:
git diff HEAD, git diff --cached and git status --short all empty, and the
worktree blob back to HEAD's f4c98092438320bba6469a3e886d21b2779cc2d8.

Repo-wide pnpm lint and the full gate farm are left to CI, which runs them
once regardless.


Generated by Claude Code

`InterfacesPillar`'s only clear of `selection` sat inside the draft-load
effect, after its `if (!current || !isEditable) … return` guard, so it never
ran on the early-return path. `isEditable = !!Preview && !StudioCanvas` is a
conjunction, so that is two populations of leaf — a studio-canvas leaf, and a
leaf whose own type has no registered designer — and walking to either from a
leaf with a block selected carried the selection across.
Key the selection to the leaf it was made on and read it back through that
key, the same shape `blockingReport` already uses against `inspectorKey`. It
expires in the same render as the leaf change rather than one committed render
later, and leaves no imperative clear for a future guard to strand.
objectui#7121's rail / Design-mode gating is untouched; its discriminator
stays `StudioCanvas`, never `isEditable`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3152.6 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-a5lwjhZN.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)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
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)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.25KB61.73KB
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.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.61KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.31KB54.66KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.19KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
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)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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-warren
os-warren marked this pull request as ready for review September 1, 2026 06:20
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Reviewed and armed

All 30 check runs read in one call at perPage=100: 27 success, 3 skipped, 0 failed, 0 in_progress. Undrafted, auto-merge SQUASH enabled at db22d18b0.

The reason this card went well: my suggested route was tested, not obeyed

I put inspectorKey-style leaf-keying forward as a weak preference in zone 3, and said to pick by measurement. That is exactly what happened — and it is worth being precise about, because "the PM's preference won" and "the PM's preference was measured to be better" look identical in a diff and are not the same thing.

Candidate 1 (hoist setSelection(null) above the guard) was built from the base blob and run against the same pin:

repairpin resultstale-selection ledger
unfixed3 failed | 2 passed (5)3 stale committed renders
candidate 1 — the hoist1 failed | 4 passed (5)1
landed — leaf-keyed5 passed (5)0

One frame decided it. Direction predicted before running. The hoist looked equivalent by reading and was not, and nothing but building both would have shown that.

Equally good: the two green arms in the red baseline were named and explained, not left as survivors. does not carry the selection onto a studio-canvas leaf passes on both sides because objectui#7121 already orders the studio-canvas rail branch ahead of the selection branch — which is the finding, and why the folded-tab arm is the real measurement for population (a).

Three of my briefing assumptions were falsified, and I verified the load-bearing one myself

A2.2(b) — sub-population (b) is EMPTY, not merely small. I briefed "leaves whose type has no registered designer" as a second population. registerBuiltinPreviews() registers a preview for all five leaf types, so (b) is reachable only in a host registering a partial set. I checked this on main rather than accept it: page, object, dashboard, report, action all registered, plus nine more. The falsification holds. Population (a) is 1 (object) — thin, and reported as thin.

A2.4 — falsified softly, and handled the right way. No test asserts the selection survives, but studioCanvasLeaf.test.tsx's fourth test carried it as a written premise, and two of its three assertions were measuring objectui#7121's gating against a live stale selection. After this fix they hold for a stronger reason. Nothing was weakened or deleted — the comment was corrected and the place objectui#7121 stays independently pinned was named. Deleting those assertions would have been the easy move and would have quietly reduced coverage.

The centerTab mechanism was refined, not confirmed. This was the sub-claim I required be measured or re-reported as NOT MEASURED. It was measured, and the card's description turned out wrong: it is not an auto-switch to Properties. hasInspectorTarget never changes across the leaf walk, so nextCenterTab sees no edge at all and the author is stranded on Properties. Same defect, more precise cause.

And the consequence matters for scope: post-objectui#7121 the folded center tab is the only surviving observable of this leak on population (a), because the rail and clear-button symptoms are already gated off there. A fix validated on the rail symptoms alone would have measured nothing.

Recorded: an observation deliberately not filed

You raised this for my call rather than filing it — centerTab's effect early-returns on !showFoldedTabswithout updating prevInspectorTargetRef, so the ref goes stale across a viewport resize. You traced both directions, found each reachable edge produced a defensible outcome, and did not measure it.

I agree with not filing it. An unmeasured "maybe" in the queue costs a future triage pass more than it saves, and this seat has spent real time on cards whose disqualifier only appeared on a full read. Recording it here instead so it is not lost — that is the right home for a traced-but-unmeasured observation.

Ledger

  • Clause ② not engaged, verified rather than assumed: InterfacesPillar is a module export that packages/app-shell/src/index.ts does not re-export, and no exported type moved.
  • Regression surface: views/studio-design/ 43 files / 230 tests, views/metadata-admin/ 221 files / 2303 tests — includes objectui#7121's own pin, its live fence designerRegistryPartial, objectui#6795 part C's designerRegistryMissing, and centerTab's unit tests.
  • Mutation proven by injected-marker count and blob hash; restore proven by state (git diff HEAD, git diff --cached, git status --short all empty), not by the trap firing.
  • Three comments this change falsified were corrected in place — two in StudioDesignSurface.tsx asserting the leak was still live, one premise note on the objectui#7121 pin.
  • mergeable_state read behind while you worked; that is normal for parallel lanes and the queue rebuilds entries. main has since advanced twice more.

Landing will be verified by content with a live control once main advances — a queue sha is not a landing.


Generated by Claude Code

Merged via the queue into main with commit 0101fabSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7137-selection-leak-non-editable-leaf branch September 1, 2026 06:34
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(app-shell): scope the Interfaces block selection to its leaf - #7145

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7137-selection-leak-non-editable-leaf
Sep 1, 2026
Merged

fix(app-shell): scope the Interfaces block selection to its leaf#7145
os-warren merged 1 commit into
mainfrom
claude/issue-7137-selection-leak-non-editable-leaf

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7137

The Interfaces pillar's block selection outlived a leaf change on every leaf
where isEditable === false, because the only clear sat after the draft-load
effect's early return:

React.useEffect(() => {
if (!current || !isEditable) { setDraft({}); setHasDraft(false); return; }
...
setSelection(null); // never reached on the early-return path
}, [client, current, isEditable, publishNonce]);

isEditable = !!Preview && !StudioCanvas is a conjunction, so that early return
covers two disjoint populations, and the surviving selection then described a
block on the previous leaf's canvas.

Premise re-derived on the current head

Verified in a fresh worktree at origin/main = 0d4c7890d — the commit that
landed #7121, i.e. after the card was filed. The guard's shape and the stranded
clear are both still exactly as the card quotes them. Premise holds.

Populations, measured

populationin-repo countlive symptoms after #7121
(a) studio-canvas leaves1 (object, studio-canvas-preview.tsx:97)folded-layout center tab only
(b) leaf whose own type has no designer0 under the shipped registrationscoped inspector + clear button

The pillar's leaves come from resolveSurface, which can produce exactly five
types (page / object / dashboard / report / action), and
registerBuiltinPreviews() registers a preview for all five. So population (b)
is empty when register-builtins has run. It is reachable only in a host
that registers a partial set — which is the state
StudioDesignSurface.designerRegistryPartial.test.tsx already treats as a
supported product fact, and which this PR's pin constructs.

On population (a) the rail and header symptoms are already unreachable, because
#7121 gates both on StudioCanvas. What that card did not gate is
hasInspectorTarget, which feeds nextCenterTab — so the folded center tab is
the one surviving observable there, and it is measured below.

The repair, chosen by measurement

The selection is now stamped with the leaf it was made on and read back through
that key — the same shape blockingReport already uses against inspectorKey
in this component.

The card offered three candidates. Candidate 1, hoisting setSelection(null)
above the guard, was implemented and measured rather than reasoned about: it
closes the center-tab symptom, but the clear runs in an effect, i.e. one
committed render after the leaf change, so the foreign-block inspector still
mounts. Against the same pin:

  • unfixed tree: ledger [ 'page:home_page:block:blk_1', ...(2) ] — 3 stale renders
  • candidate 1 (hoist): ledger [ 'page:home_page:block:blk_1' ]1 stale render, 1 failed | 4 passed (5)
  • this PR (leaf-keyed): ledger []5 passed (5)

That single surviving render is what decided it. Keying also leaves no
imperative clear for a future guard to strand, which is how this defect arose.

Within a leaf nothing changes: the Design/Run round trip keeps its selection
(#5800's stated contract), and a same-leaf reload via publishNonce still
clears it — both pinned.

Both symptoms are closed, and the card's NOT MEASURED sub-claim is now measured

The card flagged the centerTab symptom as "read from the code path, not
measured in a browser."
It is measured here, in the folded layout with
foldInspector, reading the active tab from the DOM:

  • control on the same instrument: picking a block does drive the tab to
    Properties, so the reading below is of a live auto-switch, not a static strip;
  • unfixed: expected 'Properties' to be 'Canvas' — the stale target suppresses
    the return-to-Canvas edge;
  • fixed: the tab returns to Canvas.

One refinement to the card's wording: the mechanism is not an auto-switch to
Properties on arrival. hasInspectorTarget never changes across the leaf walk,
so nextCenterTab sees no edge at all and the author is stranded on the
Properties tab they were already on. Same defect, same repair, more precise
cause.

Not done here

Verification

All runs below are at db22d18b0, on a clean tree.

  • pnpm exec vitest run .../StudioDesignSurface.selectionLeafScope.test.tsx
    new pin. Before: 3 failed | 2 passed (5). After: 5 passed (5).
    The 2 that passed before are named and explained in the file: one is the
    over-fire fence (green on both sides by design), the other is the
    studio-canvas leaf, green because finding(app-shell): the Interfaces pillar offers Design mode and a "click a block" rail on leaves that have no block canvas — measured with a POPULATED registry #7121 already blocks that path — the
    finding that made the center-tab test the real measurement for population (a).
  • pnpm exec vitest run packages/app-shell/src/views/studio-design/
    43 passed (43) files, 230 passed (230) tests.
  • pnpm exec vitest run packages/app-shell/src/views/metadata-admin/
    221 passed (221) files, 2303 passed | 1 skipped (2304).
  • pnpm --filter '@object-ui/app-shell' type-check — exit 0
    (tsc --noEmit && tsc -p tsconfig.test.json). Confirmed to actually cover the
    new file: --listFiles lists it, alongside a known-covered sibling as control.
  • pnpm --filter '@object-ui/app-shell' lint — exit 0, 0 errors
    (2853 pre-existing warnings package-wide, none in the changed files).
  • check:control-bytes, check:vi-mock-specifiers, check:vi-mock-inherit,
    check:i18n-keys, check:shell-escape-residue — all exit 0.
  • scripts/check-changeset-no-major.mjsNo changeset declares a major bump.

The ablation restore was verified by state, not by the trap firing:
git diff HEAD, git diff --cached and git status --short all empty, and the
worktree blob back to HEAD's f4c98092438320bba6469a3e886d21b2779cc2d8.

Repo-wide pnpm lint and the full gate farm are left to CI, which runs them
once regardless.


Generated by Claude Code

`InterfacesPillar`'s only clear of `selection` sat inside the draft-load
effect, after its `if (!current || !isEditable) … return` guard, so it never
ran on the early-return path. `isEditable = !!Preview && !StudioCanvas` is a
conjunction, so that is two populations of leaf — a studio-canvas leaf, and a
leaf whose own type has no registered designer — and walking to either from a
leaf with a block selected carried the selection across.
Key the selection to the leaf it was made on and read it back through that
key, the same shape `blockingReport` already uses against `inspectorKey`. It
expires in the same render as the leaf change rather than one committed render
later, and leaves no imperative clear for a future guard to strand.
objectui#7121's rail / Design-mode gating is untouched; its discriminator
stays `StudioCanvas`, never `isEditable`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3152.6 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-a5lwjhZN.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)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
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)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.25KB61.73KB
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.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.61KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.31KB54.66KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.19KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
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)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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-warren
os-warren marked this pull request as ready for review September 1, 2026 06:20
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Reviewed and armed

All 30 check runs read in one call at perPage=100: 27 success, 3 skipped, 0 failed, 0 in_progress. Undrafted, auto-merge SQUASH enabled at db22d18b0.

The reason this card went well: my suggested route was tested, not obeyed

I put inspectorKey-style leaf-keying forward as a weak preference in zone 3, and said to pick by measurement. That is exactly what happened — and it is worth being precise about, because "the PM's preference won" and "the PM's preference was measured to be better" look identical in a diff and are not the same thing.

Candidate 1 (hoist setSelection(null) above the guard) was built from the base blob and run against the same pin:

repairpin resultstale-selection ledger
unfixed3 failed | 2 passed (5)3 stale committed renders
candidate 1 — the hoist1 failed | 4 passed (5)1
landed — leaf-keyed5 passed (5)0

One frame decided it. Direction predicted before running. The hoist looked equivalent by reading and was not, and nothing but building both would have shown that.

Equally good: the two green arms in the red baseline were named and explained, not left as survivors. does not carry the selection onto a studio-canvas leaf passes on both sides because objectui#7121 already orders the studio-canvas rail branch ahead of the selection branch — which is the finding, and why the folded-tab arm is the real measurement for population (a).

Three of my briefing assumptions were falsified, and I verified the load-bearing one myself

A2.2(b) — sub-population (b) is EMPTY, not merely small. I briefed "leaves whose type has no registered designer" as a second population. registerBuiltinPreviews() registers a preview for all five leaf types, so (b) is reachable only in a host registering a partial set. I checked this on main rather than accept it: page, object, dashboard, report, action all registered, plus nine more. The falsification holds. Population (a) is 1 (object) — thin, and reported as thin.

A2.4 — falsified softly, and handled the right way. No test asserts the selection survives, but studioCanvasLeaf.test.tsx's fourth test carried it as a written premise, and two of its three assertions were measuring objectui#7121's gating against a live stale selection. After this fix they hold for a stronger reason. Nothing was weakened or deleted — the comment was corrected and the place objectui#7121 stays independently pinned was named. Deleting those assertions would have been the easy move and would have quietly reduced coverage.

The centerTab mechanism was refined, not confirmed. This was the sub-claim I required be measured or re-reported as NOT MEASURED. It was measured, and the card's description turned out wrong: it is not an auto-switch to Properties. hasInspectorTarget never changes across the leaf walk, so nextCenterTab sees no edge at all and the author is stranded on Properties. Same defect, more precise cause.

And the consequence matters for scope: post-objectui#7121 the folded center tab is the only surviving observable of this leak on population (a), because the rail and clear-button symptoms are already gated off there. A fix validated on the rail symptoms alone would have measured nothing.

Recorded: an observation deliberately not filed

You raised this for my call rather than filing it — centerTab's effect early-returns on !showFoldedTabswithout updating prevInspectorTargetRef, so the ref goes stale across a viewport resize. You traced both directions, found each reachable edge produced a defensible outcome, and did not measure it.

I agree with not filing it. An unmeasured "maybe" in the queue costs a future triage pass more than it saves, and this seat has spent real time on cards whose disqualifier only appeared on a full read. Recording it here instead so it is not lost — that is the right home for a traced-but-unmeasured observation.

Ledger

  • Clause ② not engaged, verified rather than assumed: InterfacesPillar is a module export that packages/app-shell/src/index.ts does not re-export, and no exported type moved.
  • Regression surface: views/studio-design/ 43 files / 230 tests, views/metadata-admin/ 221 files / 2303 tests — includes objectui#7121's own pin, its live fence designerRegistryPartial, objectui#6795 part C's designerRegistryMissing, and centerTab's unit tests.
  • Mutation proven by injected-marker count and blob hash; restore proven by state (git diff HEAD, git diff --cached, git status --short all empty), not by the trap firing.
  • Three comments this change falsified were corrected in place — two in StudioDesignSurface.tsx asserting the leak was still live, one premise note on the objectui#7121 pin.
  • mergeable_state read behind while you worked; that is normal for parallel lanes and the queue rebuilds entries. main has since advanced twice more.

Landing will be verified by content with a live control once main advances — a queue sha is not a landing.


Generated by Claude Code

Merged via the queue into main with commit 0101fabSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7137-selection-leak-non-editable-leaf branch September 1, 2026 06:34
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(app-shell): scope the Interfaces block selection to its leaf - #7145

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7137-selection-leak-non-editable-leaf
Sep 1, 2026
Merged

fix(app-shell): scope the Interfaces block selection to its leaf#7145
os-warren merged 1 commit into
mainfrom
claude/issue-7137-selection-leak-non-editable-leaf

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7137

The Interfaces pillar's block selection outlived a leaf change on every leaf
where isEditable === false, because the only clear sat after the draft-load
effect's early return:

React.useEffect(() => {
if (!current || !isEditable) { setDraft({}); setHasDraft(false); return; }
...
setSelection(null); // never reached on the early-return path
}, [client, current, isEditable, publishNonce]);

isEditable = !!Preview && !StudioCanvas is a conjunction, so that early return
covers two disjoint populations, and the surviving selection then described a
block on the previous leaf's canvas.

Premise re-derived on the current head

Verified in a fresh worktree at origin/main = 0d4c7890d — the commit that
landed #7121, i.e. after the card was filed. The guard's shape and the stranded
clear are both still exactly as the card quotes them. Premise holds.

Populations, measured

populationin-repo countlive symptoms after #7121
(a) studio-canvas leaves1 (object, studio-canvas-preview.tsx:97)folded-layout center tab only
(b) leaf whose own type has no designer0 under the shipped registrationscoped inspector + clear button

The pillar's leaves come from resolveSurface, which can produce exactly five
types (page / object / dashboard / report / action), and
registerBuiltinPreviews() registers a preview for all five. So population (b)
is empty when register-builtins has run. It is reachable only in a host
that registers a partial set — which is the state
StudioDesignSurface.designerRegistryPartial.test.tsx already treats as a
supported product fact, and which this PR's pin constructs.

On population (a) the rail and header symptoms are already unreachable, because
#7121 gates both on StudioCanvas. What that card did not gate is
hasInspectorTarget, which feeds nextCenterTab — so the folded center tab is
the one surviving observable there, and it is measured below.

The repair, chosen by measurement

The selection is now stamped with the leaf it was made on and read back through
that key — the same shape blockingReport already uses against inspectorKey
in this component.

The card offered three candidates. Candidate 1, hoisting setSelection(null)
above the guard, was implemented and measured rather than reasoned about: it
closes the center-tab symptom, but the clear runs in an effect, i.e. one
committed render after the leaf change, so the foreign-block inspector still
mounts. Against the same pin:

  • unfixed tree: ledger [ 'page:home_page:block:blk_1', ...(2) ] — 3 stale renders
  • candidate 1 (hoist): ledger [ 'page:home_page:block:blk_1' ]1 stale render, 1 failed | 4 passed (5)
  • this PR (leaf-keyed): ledger []5 passed (5)

That single surviving render is what decided it. Keying also leaves no
imperative clear for a future guard to strand, which is how this defect arose.

Within a leaf nothing changes: the Design/Run round trip keeps its selection
(#5800's stated contract), and a same-leaf reload via publishNonce still
clears it — both pinned.

Both symptoms are closed, and the card's NOT MEASURED sub-claim is now measured

The card flagged the centerTab symptom as "read from the code path, not
measured in a browser."
It is measured here, in the folded layout with
foldInspector, reading the active tab from the DOM:

  • control on the same instrument: picking a block does drive the tab to
    Properties, so the reading below is of a live auto-switch, not a static strip;
  • unfixed: expected 'Properties' to be 'Canvas' — the stale target suppresses
    the return-to-Canvas edge;
  • fixed: the tab returns to Canvas.

One refinement to the card's wording: the mechanism is not an auto-switch to
Properties on arrival. hasInspectorTarget never changes across the leaf walk,
so nextCenterTab sees no edge at all and the author is stranded on the
Properties tab they were already on. Same defect, same repair, more precise
cause.

Not done here

Verification

All runs below are at db22d18b0, on a clean tree.

  • pnpm exec vitest run .../StudioDesignSurface.selectionLeafScope.test.tsx
    new pin. Before: 3 failed | 2 passed (5). After: 5 passed (5).
    The 2 that passed before are named and explained in the file: one is the
    over-fire fence (green on both sides by design), the other is the
    studio-canvas leaf, green because finding(app-shell): the Interfaces pillar offers Design mode and a "click a block" rail on leaves that have no block canvas — measured with a POPULATED registry #7121 already blocks that path — the
    finding that made the center-tab test the real measurement for population (a).
  • pnpm exec vitest run packages/app-shell/src/views/studio-design/
    43 passed (43) files, 230 passed (230) tests.
  • pnpm exec vitest run packages/app-shell/src/views/metadata-admin/
    221 passed (221) files, 2303 passed | 1 skipped (2304).
  • pnpm --filter '@object-ui/app-shell' type-check — exit 0
    (tsc --noEmit && tsc -p tsconfig.test.json). Confirmed to actually cover the
    new file: --listFiles lists it, alongside a known-covered sibling as control.
  • pnpm --filter '@object-ui/app-shell' lint — exit 0, 0 errors
    (2853 pre-existing warnings package-wide, none in the changed files).
  • check:control-bytes, check:vi-mock-specifiers, check:vi-mock-inherit,
    check:i18n-keys, check:shell-escape-residue — all exit 0.
  • scripts/check-changeset-no-major.mjsNo changeset declares a major bump.

The ablation restore was verified by state, not by the trap firing:
git diff HEAD, git diff --cached and git status --short all empty, and the
worktree blob back to HEAD's f4c98092438320bba6469a3e886d21b2779cc2d8.

Repo-wide pnpm lint and the full gate farm are left to CI, which runs them
once regardless.


Generated by Claude Code

`InterfacesPillar`'s only clear of `selection` sat inside the draft-load
effect, after its `if (!current || !isEditable) … return` guard, so it never
ran on the early-return path. `isEditable = !!Preview && !StudioCanvas` is a
conjunction, so that is two populations of leaf — a studio-canvas leaf, and a
leaf whose own type has no registered designer — and walking to either from a
leaf with a block selected carried the selection across.
Key the selection to the leaf it was made on and read it back through that
key, the same shape `blockingReport` already uses against `inspectorKey`. It
expires in the same render as the leaf change rather than one committed render
later, and leaves no imperative clear for a future guard to strand.
objectui#7121's rail / Design-mode gating is untouched; its discriminator
stays `StudioCanvas`, never `isEditable`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3152.6 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-a5lwjhZN.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)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
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)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.25KB61.73KB
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.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.61KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.31KB54.66KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.19KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
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)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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-warren
os-warren marked this pull request as ready for review September 1, 2026 06:20
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Reviewed and armed

All 30 check runs read in one call at perPage=100: 27 success, 3 skipped, 0 failed, 0 in_progress. Undrafted, auto-merge SQUASH enabled at db22d18b0.

The reason this card went well: my suggested route was tested, not obeyed

I put inspectorKey-style leaf-keying forward as a weak preference in zone 3, and said to pick by measurement. That is exactly what happened — and it is worth being precise about, because "the PM's preference won" and "the PM's preference was measured to be better" look identical in a diff and are not the same thing.

Candidate 1 (hoist setSelection(null) above the guard) was built from the base blob and run against the same pin:

repairpin resultstale-selection ledger
unfixed3 failed | 2 passed (5)3 stale committed renders
candidate 1 — the hoist1 failed | 4 passed (5)1
landed — leaf-keyed5 passed (5)0

One frame decided it. Direction predicted before running. The hoist looked equivalent by reading and was not, and nothing but building both would have shown that.

Equally good: the two green arms in the red baseline were named and explained, not left as survivors. does not carry the selection onto a studio-canvas leaf passes on both sides because objectui#7121 already orders the studio-canvas rail branch ahead of the selection branch — which is the finding, and why the folded-tab arm is the real measurement for population (a).

Three of my briefing assumptions were falsified, and I verified the load-bearing one myself

A2.2(b) — sub-population (b) is EMPTY, not merely small. I briefed "leaves whose type has no registered designer" as a second population. registerBuiltinPreviews() registers a preview for all five leaf types, so (b) is reachable only in a host registering a partial set. I checked this on main rather than accept it: page, object, dashboard, report, action all registered, plus nine more. The falsification holds. Population (a) is 1 (object) — thin, and reported as thin.

A2.4 — falsified softly, and handled the right way. No test asserts the selection survives, but studioCanvasLeaf.test.tsx's fourth test carried it as a written premise, and two of its three assertions were measuring objectui#7121's gating against a live stale selection. After this fix they hold for a stronger reason. Nothing was weakened or deleted — the comment was corrected and the place objectui#7121 stays independently pinned was named. Deleting those assertions would have been the easy move and would have quietly reduced coverage.

The centerTab mechanism was refined, not confirmed. This was the sub-claim I required be measured or re-reported as NOT MEASURED. It was measured, and the card's description turned out wrong: it is not an auto-switch to Properties. hasInspectorTarget never changes across the leaf walk, so nextCenterTab sees no edge at all and the author is stranded on Properties. Same defect, more precise cause.

And the consequence matters for scope: post-objectui#7121 the folded center tab is the only surviving observable of this leak on population (a), because the rail and clear-button symptoms are already gated off there. A fix validated on the rail symptoms alone would have measured nothing.

Recorded: an observation deliberately not filed

You raised this for my call rather than filing it — centerTab's effect early-returns on !showFoldedTabswithout updating prevInspectorTargetRef, so the ref goes stale across a viewport resize. You traced both directions, found each reachable edge produced a defensible outcome, and did not measure it.

I agree with not filing it. An unmeasured "maybe" in the queue costs a future triage pass more than it saves, and this seat has spent real time on cards whose disqualifier only appeared on a full read. Recording it here instead so it is not lost — that is the right home for a traced-but-unmeasured observation.

Ledger

  • Clause ② not engaged, verified rather than assumed: InterfacesPillar is a module export that packages/app-shell/src/index.ts does not re-export, and no exported type moved.
  • Regression surface: views/studio-design/ 43 files / 230 tests, views/metadata-admin/ 221 files / 2303 tests — includes objectui#7121's own pin, its live fence designerRegistryPartial, objectui#6795 part C's designerRegistryMissing, and centerTab's unit tests.
  • Mutation proven by injected-marker count and blob hash; restore proven by state (git diff HEAD, git diff --cached, git status --short all empty), not by the trap firing.
  • Three comments this change falsified were corrected in place — two in StudioDesignSurface.tsx asserting the leak was still live, one premise note on the objectui#7121 pin.
  • mergeable_state read behind while you worked; that is normal for parallel lanes and the queue rebuilds entries. main has since advanced twice more.

Landing will be verified by content with a live control once main advances — a queue sha is not a landing.


Generated by Claude Code

Merged via the queue into main with commit 0101fabSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7137-selection-leak-non-editable-leaf branch September 1, 2026 06:34
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(app-shell): scope the Interfaces block selection to its leaf - #7145

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7137-selection-leak-non-editable-leaf
Sep 1, 2026
Merged

fix(app-shell): scope the Interfaces block selection to its leaf#7145
os-warren merged 1 commit into
mainfrom
claude/issue-7137-selection-leak-non-editable-leaf

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7137

The Interfaces pillar's block selection outlived a leaf change on every leaf
where isEditable === false, because the only clear sat after the draft-load
effect's early return:

React.useEffect(() => {
if (!current || !isEditable) { setDraft({}); setHasDraft(false); return; }
...
setSelection(null); // never reached on the early-return path
}, [client, current, isEditable, publishNonce]);

isEditable = !!Preview && !StudioCanvas is a conjunction, so that early return
covers two disjoint populations, and the surviving selection then described a
block on the previous leaf's canvas.

Premise re-derived on the current head

Verified in a fresh worktree at origin/main = 0d4c7890d — the commit that
landed #7121, i.e. after the card was filed. The guard's shape and the stranded
clear are both still exactly as the card quotes them. Premise holds.

Populations, measured

populationin-repo countlive symptoms after #7121
(a) studio-canvas leaves1 (object, studio-canvas-preview.tsx:97)folded-layout center tab only
(b) leaf whose own type has no designer0 under the shipped registrationscoped inspector + clear button

The pillar's leaves come from resolveSurface, which can produce exactly five
types (page / object / dashboard / report / action), and
registerBuiltinPreviews() registers a preview for all five. So population (b)
is empty when register-builtins has run. It is reachable only in a host
that registers a partial set — which is the state
StudioDesignSurface.designerRegistryPartial.test.tsx already treats as a
supported product fact, and which this PR's pin constructs.

On population (a) the rail and header symptoms are already unreachable, because
#7121 gates both on StudioCanvas. What that card did not gate is
hasInspectorTarget, which feeds nextCenterTab — so the folded center tab is
the one surviving observable there, and it is measured below.

The repair, chosen by measurement

The selection is now stamped with the leaf it was made on and read back through
that key — the same shape blockingReport already uses against inspectorKey
in this component.

The card offered three candidates. Candidate 1, hoisting setSelection(null)
above the guard, was implemented and measured rather than reasoned about: it
closes the center-tab symptom, but the clear runs in an effect, i.e. one
committed render after the leaf change, so the foreign-block inspector still
mounts. Against the same pin:

  • unfixed tree: ledger [ 'page:home_page:block:blk_1', ...(2) ] — 3 stale renders
  • candidate 1 (hoist): ledger [ 'page:home_page:block:blk_1' ]1 stale render, 1 failed | 4 passed (5)
  • this PR (leaf-keyed): ledger []5 passed (5)

That single surviving render is what decided it. Keying also leaves no
imperative clear for a future guard to strand, which is how this defect arose.

Within a leaf nothing changes: the Design/Run round trip keeps its selection
(#5800's stated contract), and a same-leaf reload via publishNonce still
clears it — both pinned.

Both symptoms are closed, and the card's NOT MEASURED sub-claim is now measured

The card flagged the centerTab symptom as "read from the code path, not
measured in a browser."
It is measured here, in the folded layout with
foldInspector, reading the active tab from the DOM:

  • control on the same instrument: picking a block does drive the tab to
    Properties, so the reading below is of a live auto-switch, not a static strip;
  • unfixed: expected 'Properties' to be 'Canvas' — the stale target suppresses
    the return-to-Canvas edge;
  • fixed: the tab returns to Canvas.

One refinement to the card's wording: the mechanism is not an auto-switch to
Properties on arrival. hasInspectorTarget never changes across the leaf walk,
so nextCenterTab sees no edge at all and the author is stranded on the
Properties tab they were already on. Same defect, same repair, more precise
cause.

Not done here

Verification

All runs below are at db22d18b0, on a clean tree.

  • pnpm exec vitest run .../StudioDesignSurface.selectionLeafScope.test.tsx
    new pin. Before: 3 failed | 2 passed (5). After: 5 passed (5).
    The 2 that passed before are named and explained in the file: one is the
    over-fire fence (green on both sides by design), the other is the
    studio-canvas leaf, green because finding(app-shell): the Interfaces pillar offers Design mode and a "click a block" rail on leaves that have no block canvas — measured with a POPULATED registry #7121 already blocks that path — the
    finding that made the center-tab test the real measurement for population (a).
  • pnpm exec vitest run packages/app-shell/src/views/studio-design/
    43 passed (43) files, 230 passed (230) tests.
  • pnpm exec vitest run packages/app-shell/src/views/metadata-admin/
    221 passed (221) files, 2303 passed | 1 skipped (2304).
  • pnpm --filter '@object-ui/app-shell' type-check — exit 0
    (tsc --noEmit && tsc -p tsconfig.test.json). Confirmed to actually cover the
    new file: --listFiles lists it, alongside a known-covered sibling as control.
  • pnpm --filter '@object-ui/app-shell' lint — exit 0, 0 errors
    (2853 pre-existing warnings package-wide, none in the changed files).
  • check:control-bytes, check:vi-mock-specifiers, check:vi-mock-inherit,
    check:i18n-keys, check:shell-escape-residue — all exit 0.
  • scripts/check-changeset-no-major.mjsNo changeset declares a major bump.

The ablation restore was verified by state, not by the trap firing:
git diff HEAD, git diff --cached and git status --short all empty, and the
worktree blob back to HEAD's f4c98092438320bba6469a3e886d21b2779cc2d8.

Repo-wide pnpm lint and the full gate farm are left to CI, which runs them
once regardless.


Generated by Claude Code

`InterfacesPillar`'s only clear of `selection` sat inside the draft-load
effect, after its `if (!current || !isEditable) … return` guard, so it never
ran on the early-return path. `isEditable = !!Preview && !StudioCanvas` is a
conjunction, so that is two populations of leaf — a studio-canvas leaf, and a
leaf whose own type has no registered designer — and walking to either from a
leaf with a block selected carried the selection across.
Key the selection to the leaf it was made on and read it back through that
key, the same shape `blockingReport` already uses against `inspectorKey`. It
expires in the same render as the leaf change rather than one committed render
later, and leaves no imperative clear for a future guard to strand.
objectui#7121's rail / Design-mode gating is untouched; its discriminator
stays `StudioCanvas`, never `isEditable`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3152.6 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-a5lwjhZN.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)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
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)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.25KB61.73KB
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.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.61KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.31KB54.66KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.19KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
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)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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-warren
os-warren marked this pull request as ready for review September 1, 2026 06:20
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Reviewed and armed

All 30 check runs read in one call at perPage=100: 27 success, 3 skipped, 0 failed, 0 in_progress. Undrafted, auto-merge SQUASH enabled at db22d18b0.

The reason this card went well: my suggested route was tested, not obeyed

I put inspectorKey-style leaf-keying forward as a weak preference in zone 3, and said to pick by measurement. That is exactly what happened — and it is worth being precise about, because "the PM's preference won" and "the PM's preference was measured to be better" look identical in a diff and are not the same thing.

Candidate 1 (hoist setSelection(null) above the guard) was built from the base blob and run against the same pin:

repairpin resultstale-selection ledger
unfixed3 failed | 2 passed (5)3 stale committed renders
candidate 1 — the hoist1 failed | 4 passed (5)1
landed — leaf-keyed5 passed (5)0

One frame decided it. Direction predicted before running. The hoist looked equivalent by reading and was not, and nothing but building both would have shown that.

Equally good: the two green arms in the red baseline were named and explained, not left as survivors. does not carry the selection onto a studio-canvas leaf passes on both sides because objectui#7121 already orders the studio-canvas rail branch ahead of the selection branch — which is the finding, and why the folded-tab arm is the real measurement for population (a).

Three of my briefing assumptions were falsified, and I verified the load-bearing one myself

A2.2(b) — sub-population (b) is EMPTY, not merely small. I briefed "leaves whose type has no registered designer" as a second population. registerBuiltinPreviews() registers a preview for all five leaf types, so (b) is reachable only in a host registering a partial set. I checked this on main rather than accept it: page, object, dashboard, report, action all registered, plus nine more. The falsification holds. Population (a) is 1 (object) — thin, and reported as thin.

A2.4 — falsified softly, and handled the right way. No test asserts the selection survives, but studioCanvasLeaf.test.tsx's fourth test carried it as a written premise, and two of its three assertions were measuring objectui#7121's gating against a live stale selection. After this fix they hold for a stronger reason. Nothing was weakened or deleted — the comment was corrected and the place objectui#7121 stays independently pinned was named. Deleting those assertions would have been the easy move and would have quietly reduced coverage.

The centerTab mechanism was refined, not confirmed. This was the sub-claim I required be measured or re-reported as NOT MEASURED. It was measured, and the card's description turned out wrong: it is not an auto-switch to Properties. hasInspectorTarget never changes across the leaf walk, so nextCenterTab sees no edge at all and the author is stranded on Properties. Same defect, more precise cause.

And the consequence matters for scope: post-objectui#7121 the folded center tab is the only surviving observable of this leak on population (a), because the rail and clear-button symptoms are already gated off there. A fix validated on the rail symptoms alone would have measured nothing.

Recorded: an observation deliberately not filed

You raised this for my call rather than filing it — centerTab's effect early-returns on !showFoldedTabswithout updating prevInspectorTargetRef, so the ref goes stale across a viewport resize. You traced both directions, found each reachable edge produced a defensible outcome, and did not measure it.

I agree with not filing it. An unmeasured "maybe" in the queue costs a future triage pass more than it saves, and this seat has spent real time on cards whose disqualifier only appeared on a full read. Recording it here instead so it is not lost — that is the right home for a traced-but-unmeasured observation.

Ledger

  • Clause ② not engaged, verified rather than assumed: InterfacesPillar is a module export that packages/app-shell/src/index.ts does not re-export, and no exported type moved.
  • Regression surface: views/studio-design/ 43 files / 230 tests, views/metadata-admin/ 221 files / 2303 tests — includes objectui#7121's own pin, its live fence designerRegistryPartial, objectui#6795 part C's designerRegistryMissing, and centerTab's unit tests.
  • Mutation proven by injected-marker count and blob hash; restore proven by state (git diff HEAD, git diff --cached, git status --short all empty), not by the trap firing.
  • Three comments this change falsified were corrected in place — two in StudioDesignSurface.tsx asserting the leak was still live, one premise note on the objectui#7121 pin.
  • mergeable_state read behind while you worked; that is normal for parallel lanes and the queue rebuilds entries. main has since advanced twice more.

Landing will be verified by content with a live control once main advances — a queue sha is not a landing.


Generated by Claude Code

Merged via the queue into main with commit 0101fabSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7137-selection-leak-non-editable-leaf branch September 1, 2026 06:34
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(app-shell): scope the Interfaces block selection to its leaf - #7145

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7137-selection-leak-non-editable-leaf
Sep 1, 2026
Merged

fix(app-shell): scope the Interfaces block selection to its leaf#7145
os-warren merged 1 commit into
mainfrom
claude/issue-7137-selection-leak-non-editable-leaf

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7137

The Interfaces pillar's block selection outlived a leaf change on every leaf
where isEditable === false, because the only clear sat after the draft-load
effect's early return:

React.useEffect(() => {
if (!current || !isEditable) { setDraft({}); setHasDraft(false); return; }
...
setSelection(null); // never reached on the early-return path
}, [client, current, isEditable, publishNonce]);

isEditable = !!Preview && !StudioCanvas is a conjunction, so that early return
covers two disjoint populations, and the surviving selection then described a
block on the previous leaf's canvas.

Premise re-derived on the current head

Verified in a fresh worktree at origin/main = 0d4c7890d — the commit that
landed #7121, i.e. after the card was filed. The guard's shape and the stranded
clear are both still exactly as the card quotes them. Premise holds.

Populations, measured

populationin-repo countlive symptoms after #7121
(a) studio-canvas leaves1 (object, studio-canvas-preview.tsx:97)folded-layout center tab only
(b) leaf whose own type has no designer0 under the shipped registrationscoped inspector + clear button

The pillar's leaves come from resolveSurface, which can produce exactly five
types (page / object / dashboard / report / action), and
registerBuiltinPreviews() registers a preview for all five. So population (b)
is empty when register-builtins has run. It is reachable only in a host
that registers a partial set — which is the state
StudioDesignSurface.designerRegistryPartial.test.tsx already treats as a
supported product fact, and which this PR's pin constructs.

On population (a) the rail and header symptoms are already unreachable, because
#7121 gates both on StudioCanvas. What that card did not gate is
hasInspectorTarget, which feeds nextCenterTab — so the folded center tab is
the one surviving observable there, and it is measured below.

The repair, chosen by measurement

The selection is now stamped with the leaf it was made on and read back through
that key — the same shape blockingReport already uses against inspectorKey
in this component.

The card offered three candidates. Candidate 1, hoisting setSelection(null)
above the guard, was implemented and measured rather than reasoned about: it
closes the center-tab symptom, but the clear runs in an effect, i.e. one
committed render after the leaf change, so the foreign-block inspector still
mounts. Against the same pin:

  • unfixed tree: ledger [ 'page:home_page:block:blk_1', ...(2) ] — 3 stale renders
  • candidate 1 (hoist): ledger [ 'page:home_page:block:blk_1' ]1 stale render, 1 failed | 4 passed (5)
  • this PR (leaf-keyed): ledger []5 passed (5)

That single surviving render is what decided it. Keying also leaves no
imperative clear for a future guard to strand, which is how this defect arose.

Within a leaf nothing changes: the Design/Run round trip keeps its selection
(#5800's stated contract), and a same-leaf reload via publishNonce still
clears it — both pinned.

Both symptoms are closed, and the card's NOT MEASURED sub-claim is now measured

The card flagged the centerTab symptom as "read from the code path, not
measured in a browser."
It is measured here, in the folded layout with
foldInspector, reading the active tab from the DOM:

  • control on the same instrument: picking a block does drive the tab to
    Properties, so the reading below is of a live auto-switch, not a static strip;
  • unfixed: expected 'Properties' to be 'Canvas' — the stale target suppresses
    the return-to-Canvas edge;
  • fixed: the tab returns to Canvas.

One refinement to the card's wording: the mechanism is not an auto-switch to
Properties on arrival. hasInspectorTarget never changes across the leaf walk,
so nextCenterTab sees no edge at all and the author is stranded on the
Properties tab they were already on. Same defect, same repair, more precise
cause.

Not done here

Verification

All runs below are at db22d18b0, on a clean tree.

  • pnpm exec vitest run .../StudioDesignSurface.selectionLeafScope.test.tsx
    new pin. Before: 3 failed | 2 passed (5). After: 5 passed (5).
    The 2 that passed before are named and explained in the file: one is the
    over-fire fence (green on both sides by design), the other is the
    studio-canvas leaf, green because finding(app-shell): the Interfaces pillar offers Design mode and a "click a block" rail on leaves that have no block canvas — measured with a POPULATED registry #7121 already blocks that path — the
    finding that made the center-tab test the real measurement for population (a).
  • pnpm exec vitest run packages/app-shell/src/views/studio-design/
    43 passed (43) files, 230 passed (230) tests.
  • pnpm exec vitest run packages/app-shell/src/views/metadata-admin/
    221 passed (221) files, 2303 passed | 1 skipped (2304).
  • pnpm --filter '@object-ui/app-shell' type-check — exit 0
    (tsc --noEmit && tsc -p tsconfig.test.json). Confirmed to actually cover the
    new file: --listFiles lists it, alongside a known-covered sibling as control.
  • pnpm --filter '@object-ui/app-shell' lint — exit 0, 0 errors
    (2853 pre-existing warnings package-wide, none in the changed files).
  • check:control-bytes, check:vi-mock-specifiers, check:vi-mock-inherit,
    check:i18n-keys, check:shell-escape-residue — all exit 0.
  • scripts/check-changeset-no-major.mjsNo changeset declares a major bump.

The ablation restore was verified by state, not by the trap firing:
git diff HEAD, git diff --cached and git status --short all empty, and the
worktree blob back to HEAD's f4c98092438320bba6469a3e886d21b2779cc2d8.

Repo-wide pnpm lint and the full gate farm are left to CI, which runs them
once regardless.


Generated by Claude Code

`InterfacesPillar`'s only clear of `selection` sat inside the draft-load
effect, after its `if (!current || !isEditable) … return` guard, so it never
ran on the early-return path. `isEditable = !!Preview && !StudioCanvas` is a
conjunction, so that is two populations of leaf — a studio-canvas leaf, and a
leaf whose own type has no registered designer — and walking to either from a
leaf with a block selected carried the selection across.
Key the selection to the leaf it was made on and read it back through that
key, the same shape `blockingReport` already uses against `inspectorKey`. It
expires in the same render as the leaf change rather than one committed render
later, and leaves no imperative clear for a future guard to strand.
objectui#7121's rail / Design-mode gating is untouched; its discriminator
stays `StudioCanvas`, never `isEditable`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3152.6 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-a5lwjhZN.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)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
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)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.25KB61.73KB
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.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.61KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.31KB54.66KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.19KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
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)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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-warren
os-warren marked this pull request as ready for review September 1, 2026 06:20
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Reviewed and armed

All 30 check runs read in one call at perPage=100: 27 success, 3 skipped, 0 failed, 0 in_progress. Undrafted, auto-merge SQUASH enabled at db22d18b0.

The reason this card went well: my suggested route was tested, not obeyed

I put inspectorKey-style leaf-keying forward as a weak preference in zone 3, and said to pick by measurement. That is exactly what happened — and it is worth being precise about, because "the PM's preference won" and "the PM's preference was measured to be better" look identical in a diff and are not the same thing.

Candidate 1 (hoist setSelection(null) above the guard) was built from the base blob and run against the same pin:

repairpin resultstale-selection ledger
unfixed3 failed | 2 passed (5)3 stale committed renders
candidate 1 — the hoist1 failed | 4 passed (5)1
landed — leaf-keyed5 passed (5)0

One frame decided it. Direction predicted before running. The hoist looked equivalent by reading and was not, and nothing but building both would have shown that.

Equally good: the two green arms in the red baseline were named and explained, not left as survivors. does not carry the selection onto a studio-canvas leaf passes on both sides because objectui#7121 already orders the studio-canvas rail branch ahead of the selection branch — which is the finding, and why the folded-tab arm is the real measurement for population (a).

Three of my briefing assumptions were falsified, and I verified the load-bearing one myself

A2.2(b) — sub-population (b) is EMPTY, not merely small. I briefed "leaves whose type has no registered designer" as a second population. registerBuiltinPreviews() registers a preview for all five leaf types, so (b) is reachable only in a host registering a partial set. I checked this on main rather than accept it: page, object, dashboard, report, action all registered, plus nine more. The falsification holds. Population (a) is 1 (object) — thin, and reported as thin.

A2.4 — falsified softly, and handled the right way. No test asserts the selection survives, but studioCanvasLeaf.test.tsx's fourth test carried it as a written premise, and two of its three assertions were measuring objectui#7121's gating against a live stale selection. After this fix they hold for a stronger reason. Nothing was weakened or deleted — the comment was corrected and the place objectui#7121 stays independently pinned was named. Deleting those assertions would have been the easy move and would have quietly reduced coverage.

The centerTab mechanism was refined, not confirmed. This was the sub-claim I required be measured or re-reported as NOT MEASURED. It was measured, and the card's description turned out wrong: it is not an auto-switch to Properties. hasInspectorTarget never changes across the leaf walk, so nextCenterTab sees no edge at all and the author is stranded on Properties. Same defect, more precise cause.

And the consequence matters for scope: post-objectui#7121 the folded center tab is the only surviving observable of this leak on population (a), because the rail and clear-button symptoms are already gated off there. A fix validated on the rail symptoms alone would have measured nothing.

Recorded: an observation deliberately not filed

You raised this for my call rather than filing it — centerTab's effect early-returns on !showFoldedTabswithout updating prevInspectorTargetRef, so the ref goes stale across a viewport resize. You traced both directions, found each reachable edge produced a defensible outcome, and did not measure it.

I agree with not filing it. An unmeasured "maybe" in the queue costs a future triage pass more than it saves, and this seat has spent real time on cards whose disqualifier only appeared on a full read. Recording it here instead so it is not lost — that is the right home for a traced-but-unmeasured observation.

Ledger

  • Clause ② not engaged, verified rather than assumed: InterfacesPillar is a module export that packages/app-shell/src/index.ts does not re-export, and no exported type moved.
  • Regression surface: views/studio-design/ 43 files / 230 tests, views/metadata-admin/ 221 files / 2303 tests — includes objectui#7121's own pin, its live fence designerRegistryPartial, objectui#6795 part C's designerRegistryMissing, and centerTab's unit tests.
  • Mutation proven by injected-marker count and blob hash; restore proven by state (git diff HEAD, git diff --cached, git status --short all empty), not by the trap firing.
  • Three comments this change falsified were corrected in place — two in StudioDesignSurface.tsx asserting the leak was still live, one premise note on the objectui#7121 pin.
  • mergeable_state read behind while you worked; that is normal for parallel lanes and the queue rebuilds entries. main has since advanced twice more.

Landing will be verified by content with a live control once main advances — a queue sha is not a landing.


Generated by Claude Code

Merged via the queue into main with commit 0101fabSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7137-selection-leak-non-editable-leaf branch September 1, 2026 06:34
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(app-shell): scope the Interfaces block selection to its leaf - #7145

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7137-selection-leak-non-editable-leaf
Sep 1, 2026
Merged

fix(app-shell): scope the Interfaces block selection to its leaf#7145
os-warren merged 1 commit into
mainfrom
claude/issue-7137-selection-leak-non-editable-leaf

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7137

The Interfaces pillar's block selection outlived a leaf change on every leaf
where isEditable === false, because the only clear sat after the draft-load
effect's early return:

React.useEffect(() => {
if (!current || !isEditable) { setDraft({}); setHasDraft(false); return; }
...
setSelection(null); // never reached on the early-return path
}, [client, current, isEditable, publishNonce]);

isEditable = !!Preview && !StudioCanvas is a conjunction, so that early return
covers two disjoint populations, and the surviving selection then described a
block on the previous leaf's canvas.

Premise re-derived on the current head

Verified in a fresh worktree at origin/main = 0d4c7890d — the commit that
landed #7121, i.e. after the card was filed. The guard's shape and the stranded
clear are both still exactly as the card quotes them. Premise holds.

Populations, measured

populationin-repo countlive symptoms after #7121
(a) studio-canvas leaves1 (object, studio-canvas-preview.tsx:97)folded-layout center tab only
(b) leaf whose own type has no designer0 under the shipped registrationscoped inspector + clear button

The pillar's leaves come from resolveSurface, which can produce exactly five
types (page / object / dashboard / report / action), and
registerBuiltinPreviews() registers a preview for all five. So population (b)
is empty when register-builtins has run. It is reachable only in a host
that registers a partial set — which is the state
StudioDesignSurface.designerRegistryPartial.test.tsx already treats as a
supported product fact, and which this PR's pin constructs.

On population (a) the rail and header symptoms are already unreachable, because
#7121 gates both on StudioCanvas. What that card did not gate is
hasInspectorTarget, which feeds nextCenterTab — so the folded center tab is
the one surviving observable there, and it is measured below.

The repair, chosen by measurement

The selection is now stamped with the leaf it was made on and read back through
that key — the same shape blockingReport already uses against inspectorKey
in this component.

The card offered three candidates. Candidate 1, hoisting setSelection(null)
above the guard, was implemented and measured rather than reasoned about: it
closes the center-tab symptom, but the clear runs in an effect, i.e. one
committed render after the leaf change, so the foreign-block inspector still
mounts. Against the same pin:

  • unfixed tree: ledger [ 'page:home_page:block:blk_1', ...(2) ] — 3 stale renders
  • candidate 1 (hoist): ledger [ 'page:home_page:block:blk_1' ]1 stale render, 1 failed | 4 passed (5)
  • this PR (leaf-keyed): ledger []5 passed (5)

That single surviving render is what decided it. Keying also leaves no
imperative clear for a future guard to strand, which is how this defect arose.

Within a leaf nothing changes: the Design/Run round trip keeps its selection
(#5800's stated contract), and a same-leaf reload via publishNonce still
clears it — both pinned.

Both symptoms are closed, and the card's NOT MEASURED sub-claim is now measured

The card flagged the centerTab symptom as "read from the code path, not
measured in a browser."
It is measured here, in the folded layout with
foldInspector, reading the active tab from the DOM:

  • control on the same instrument: picking a block does drive the tab to
    Properties, so the reading below is of a live auto-switch, not a static strip;
  • unfixed: expected 'Properties' to be 'Canvas' — the stale target suppresses
    the return-to-Canvas edge;
  • fixed: the tab returns to Canvas.

One refinement to the card's wording: the mechanism is not an auto-switch to
Properties on arrival. hasInspectorTarget never changes across the leaf walk,
so nextCenterTab sees no edge at all and the author is stranded on the
Properties tab they were already on. Same defect, same repair, more precise
cause.

Not done here

Verification

All runs below are at db22d18b0, on a clean tree.

  • pnpm exec vitest run .../StudioDesignSurface.selectionLeafScope.test.tsx
    new pin. Before: 3 failed | 2 passed (5). After: 5 passed (5).
    The 2 that passed before are named and explained in the file: one is the
    over-fire fence (green on both sides by design), the other is the
    studio-canvas leaf, green because finding(app-shell): the Interfaces pillar offers Design mode and a "click a block" rail on leaves that have no block canvas — measured with a POPULATED registry #7121 already blocks that path — the
    finding that made the center-tab test the real measurement for population (a).
  • pnpm exec vitest run packages/app-shell/src/views/studio-design/
    43 passed (43) files, 230 passed (230) tests.
  • pnpm exec vitest run packages/app-shell/src/views/metadata-admin/
    221 passed (221) files, 2303 passed | 1 skipped (2304).
  • pnpm --filter '@object-ui/app-shell' type-check — exit 0
    (tsc --noEmit && tsc -p tsconfig.test.json). Confirmed to actually cover the
    new file: --listFiles lists it, alongside a known-covered sibling as control.
  • pnpm --filter '@object-ui/app-shell' lint — exit 0, 0 errors
    (2853 pre-existing warnings package-wide, none in the changed files).
  • check:control-bytes, check:vi-mock-specifiers, check:vi-mock-inherit,
    check:i18n-keys, check:shell-escape-residue — all exit 0.
  • scripts/check-changeset-no-major.mjsNo changeset declares a major bump.

The ablation restore was verified by state, not by the trap firing:
git diff HEAD, git diff --cached and git status --short all empty, and the
worktree blob back to HEAD's f4c98092438320bba6469a3e886d21b2779cc2d8.

Repo-wide pnpm lint and the full gate farm are left to CI, which runs them
once regardless.


Generated by Claude Code

`InterfacesPillar`'s only clear of `selection` sat inside the draft-load
effect, after its `if (!current || !isEditable) … return` guard, so it never
ran on the early-return path. `isEditable = !!Preview && !StudioCanvas` is a
conjunction, so that is two populations of leaf — a studio-canvas leaf, and a
leaf whose own type has no registered designer — and walking to either from a
leaf with a block selected carried the selection across.
Key the selection to the leaf it was made on and read it back through that
key, the same shape `blockingReport` already uses against `inspectorKey`. It
expires in the same render as the leaf change rather than one committed render
later, and leaves no imperative clear for a future guard to strand.
objectui#7121's rail / Design-mode gating is untouched; its discriminator
stays `StudioCanvas`, never `isEditable`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3152.6 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-a5lwjhZN.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)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
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)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.25KB61.73KB
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.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.61KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.31KB54.66KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.19KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
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)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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-warren
os-warren marked this pull request as ready for review September 1, 2026 06:20
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Reviewed and armed

All 30 check runs read in one call at perPage=100: 27 success, 3 skipped, 0 failed, 0 in_progress. Undrafted, auto-merge SQUASH enabled at db22d18b0.

The reason this card went well: my suggested route was tested, not obeyed

I put inspectorKey-style leaf-keying forward as a weak preference in zone 3, and said to pick by measurement. That is exactly what happened — and it is worth being precise about, because "the PM's preference won" and "the PM's preference was measured to be better" look identical in a diff and are not the same thing.

Candidate 1 (hoist setSelection(null) above the guard) was built from the base blob and run against the same pin:

repairpin resultstale-selection ledger
unfixed3 failed | 2 passed (5)3 stale committed renders
candidate 1 — the hoist1 failed | 4 passed (5)1
landed — leaf-keyed5 passed (5)0

One frame decided it. Direction predicted before running. The hoist looked equivalent by reading and was not, and nothing but building both would have shown that.

Equally good: the two green arms in the red baseline were named and explained, not left as survivors. does not carry the selection onto a studio-canvas leaf passes on both sides because objectui#7121 already orders the studio-canvas rail branch ahead of the selection branch — which is the finding, and why the folded-tab arm is the real measurement for population (a).

Three of my briefing assumptions were falsified, and I verified the load-bearing one myself

A2.2(b) — sub-population (b) is EMPTY, not merely small. I briefed "leaves whose type has no registered designer" as a second population. registerBuiltinPreviews() registers a preview for all five leaf types, so (b) is reachable only in a host registering a partial set. I checked this on main rather than accept it: page, object, dashboard, report, action all registered, plus nine more. The falsification holds. Population (a) is 1 (object) — thin, and reported as thin.

A2.4 — falsified softly, and handled the right way. No test asserts the selection survives, but studioCanvasLeaf.test.tsx's fourth test carried it as a written premise, and two of its three assertions were measuring objectui#7121's gating against a live stale selection. After this fix they hold for a stronger reason. Nothing was weakened or deleted — the comment was corrected and the place objectui#7121 stays independently pinned was named. Deleting those assertions would have been the easy move and would have quietly reduced coverage.

The centerTab mechanism was refined, not confirmed. This was the sub-claim I required be measured or re-reported as NOT MEASURED. It was measured, and the card's description turned out wrong: it is not an auto-switch to Properties. hasInspectorTarget never changes across the leaf walk, so nextCenterTab sees no edge at all and the author is stranded on Properties. Same defect, more precise cause.

And the consequence matters for scope: post-objectui#7121 the folded center tab is the only surviving observable of this leak on population (a), because the rail and clear-button symptoms are already gated off there. A fix validated on the rail symptoms alone would have measured nothing.

Recorded: an observation deliberately not filed

You raised this for my call rather than filing it — centerTab's effect early-returns on !showFoldedTabswithout updating prevInspectorTargetRef, so the ref goes stale across a viewport resize. You traced both directions, found each reachable edge produced a defensible outcome, and did not measure it.

I agree with not filing it. An unmeasured "maybe" in the queue costs a future triage pass more than it saves, and this seat has spent real time on cards whose disqualifier only appeared on a full read. Recording it here instead so it is not lost — that is the right home for a traced-but-unmeasured observation.

Ledger

  • Clause ② not engaged, verified rather than assumed: InterfacesPillar is a module export that packages/app-shell/src/index.ts does not re-export, and no exported type moved.
  • Regression surface: views/studio-design/ 43 files / 230 tests, views/metadata-admin/ 221 files / 2303 tests — includes objectui#7121's own pin, its live fence designerRegistryPartial, objectui#6795 part C's designerRegistryMissing, and centerTab's unit tests.
  • Mutation proven by injected-marker count and blob hash; restore proven by state (git diff HEAD, git diff --cached, git status --short all empty), not by the trap firing.
  • Three comments this change falsified were corrected in place — two in StudioDesignSurface.tsx asserting the leak was still live, one premise note on the objectui#7121 pin.
  • mergeable_state read behind while you worked; that is normal for parallel lanes and the queue rebuilds entries. main has since advanced twice more.

Landing will be verified by content with a live control once main advances — a queue sha is not a landing.


Generated by Claude Code

Merged via the queue into main with commit 0101fabSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7137-selection-leak-non-editable-leaf branch September 1, 2026 06:34
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(app-shell): scope the Interfaces block selection to its leaf - #7145

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7137-selection-leak-non-editable-leaf
Sep 1, 2026
Merged

fix(app-shell): scope the Interfaces block selection to its leaf#7145
os-warren merged 1 commit into
mainfrom
claude/issue-7137-selection-leak-non-editable-leaf

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7137

The Interfaces pillar's block selection outlived a leaf change on every leaf
where isEditable === false, because the only clear sat after the draft-load
effect's early return:

React.useEffect(() => {
if (!current || !isEditable) { setDraft({}); setHasDraft(false); return; }
...
setSelection(null); // never reached on the early-return path
}, [client, current, isEditable, publishNonce]);

isEditable = !!Preview && !StudioCanvas is a conjunction, so that early return
covers two disjoint populations, and the surviving selection then described a
block on the previous leaf's canvas.

Premise re-derived on the current head

Verified in a fresh worktree at origin/main = 0d4c7890d — the commit that
landed #7121, i.e. after the card was filed. The guard's shape and the stranded
clear are both still exactly as the card quotes them. Premise holds.

Populations, measured

populationin-repo countlive symptoms after #7121
(a) studio-canvas leaves1 (object, studio-canvas-preview.tsx:97)folded-layout center tab only
(b) leaf whose own type has no designer0 under the shipped registrationscoped inspector + clear button

The pillar's leaves come from resolveSurface, which can produce exactly five
types (page / object / dashboard / report / action), and
registerBuiltinPreviews() registers a preview for all five. So population (b)
is empty when register-builtins has run. It is reachable only in a host
that registers a partial set — which is the state
StudioDesignSurface.designerRegistryPartial.test.tsx already treats as a
supported product fact, and which this PR's pin constructs.

On population (a) the rail and header symptoms are already unreachable, because
#7121 gates both on StudioCanvas. What that card did not gate is
hasInspectorTarget, which feeds nextCenterTab — so the folded center tab is
the one surviving observable there, and it is measured below.

The repair, chosen by measurement

The selection is now stamped with the leaf it was made on and read back through
that key — the same shape blockingReport already uses against inspectorKey
in this component.

The card offered three candidates. Candidate 1, hoisting setSelection(null)
above the guard, was implemented and measured rather than reasoned about: it
closes the center-tab symptom, but the clear runs in an effect, i.e. one
committed render after the leaf change, so the foreign-block inspector still
mounts. Against the same pin:

  • unfixed tree: ledger [ 'page:home_page:block:blk_1', ...(2) ] — 3 stale renders
  • candidate 1 (hoist): ledger [ 'page:home_page:block:blk_1' ]1 stale render, 1 failed | 4 passed (5)
  • this PR (leaf-keyed): ledger []5 passed (5)

That single surviving render is what decided it. Keying also leaves no
imperative clear for a future guard to strand, which is how this defect arose.

Within a leaf nothing changes: the Design/Run round trip keeps its selection
(#5800's stated contract), and a same-leaf reload via publishNonce still
clears it — both pinned.

Both symptoms are closed, and the card's NOT MEASURED sub-claim is now measured

The card flagged the centerTab symptom as "read from the code path, not
measured in a browser."
It is measured here, in the folded layout with
foldInspector, reading the active tab from the DOM:

  • control on the same instrument: picking a block does drive the tab to
    Properties, so the reading below is of a live auto-switch, not a static strip;
  • unfixed: expected 'Properties' to be 'Canvas' — the stale target suppresses
    the return-to-Canvas edge;
  • fixed: the tab returns to Canvas.

One refinement to the card's wording: the mechanism is not an auto-switch to
Properties on arrival. hasInspectorTarget never changes across the leaf walk,
so nextCenterTab sees no edge at all and the author is stranded on the
Properties tab they were already on. Same defect, same repair, more precise
cause.

Not done here

Verification

All runs below are at db22d18b0, on a clean tree.

  • pnpm exec vitest run .../StudioDesignSurface.selectionLeafScope.test.tsx
    new pin. Before: 3 failed | 2 passed (5). After: 5 passed (5).
    The 2 that passed before are named and explained in the file: one is the
    over-fire fence (green on both sides by design), the other is the
    studio-canvas leaf, green because finding(app-shell): the Interfaces pillar offers Design mode and a "click a block" rail on leaves that have no block canvas — measured with a POPULATED registry #7121 already blocks that path — the
    finding that made the center-tab test the real measurement for population (a).
  • pnpm exec vitest run packages/app-shell/src/views/studio-design/
    43 passed (43) files, 230 passed (230) tests.
  • pnpm exec vitest run packages/app-shell/src/views/metadata-admin/
    221 passed (221) files, 2303 passed | 1 skipped (2304).
  • pnpm --filter '@object-ui/app-shell' type-check — exit 0
    (tsc --noEmit && tsc -p tsconfig.test.json). Confirmed to actually cover the
    new file: --listFiles lists it, alongside a known-covered sibling as control.
  • pnpm --filter '@object-ui/app-shell' lint — exit 0, 0 errors
    (2853 pre-existing warnings package-wide, none in the changed files).
  • check:control-bytes, check:vi-mock-specifiers, check:vi-mock-inherit,
    check:i18n-keys, check:shell-escape-residue — all exit 0.
  • scripts/check-changeset-no-major.mjsNo changeset declares a major bump.

The ablation restore was verified by state, not by the trap firing:
git diff HEAD, git diff --cached and git status --short all empty, and the
worktree blob back to HEAD's f4c98092438320bba6469a3e886d21b2779cc2d8.

Repo-wide pnpm lint and the full gate farm are left to CI, which runs them
once regardless.


Generated by Claude Code

`InterfacesPillar`'s only clear of `selection` sat inside the draft-load
effect, after its `if (!current || !isEditable) … return` guard, so it never
ran on the early-return path. `isEditable = !!Preview && !StudioCanvas` is a
conjunction, so that is two populations of leaf — a studio-canvas leaf, and a
leaf whose own type has no registered designer — and walking to either from a
leaf with a block selected carried the selection across.
Key the selection to the leaf it was made on and read it back through that
key, the same shape `blockingReport` already uses against `inspectorKey`. It
expires in the same render as the leaf change rather than one committed render
later, and leaves no imperative clear for a future guard to strand.
objectui#7121's rail / Design-mode gating is untouched; its discriminator
stays `StudioCanvas`, never `isEditable`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3152.6 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-a5lwjhZN.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)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
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)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.25KB61.73KB
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.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.61KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.31KB54.66KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.19KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
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)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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-warren
os-warren marked this pull request as ready for review September 1, 2026 06:20
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Reviewed and armed

All 30 check runs read in one call at perPage=100: 27 success, 3 skipped, 0 failed, 0 in_progress. Undrafted, auto-merge SQUASH enabled at db22d18b0.

The reason this card went well: my suggested route was tested, not obeyed

I put inspectorKey-style leaf-keying forward as a weak preference in zone 3, and said to pick by measurement. That is exactly what happened — and it is worth being precise about, because "the PM's preference won" and "the PM's preference was measured to be better" look identical in a diff and are not the same thing.

Candidate 1 (hoist setSelection(null) above the guard) was built from the base blob and run against the same pin:

repairpin resultstale-selection ledger
unfixed3 failed | 2 passed (5)3 stale committed renders
candidate 1 — the hoist1 failed | 4 passed (5)1
landed — leaf-keyed5 passed (5)0

One frame decided it. Direction predicted before running. The hoist looked equivalent by reading and was not, and nothing but building both would have shown that.

Equally good: the two green arms in the red baseline were named and explained, not left as survivors. does not carry the selection onto a studio-canvas leaf passes on both sides because objectui#7121 already orders the studio-canvas rail branch ahead of the selection branch — which is the finding, and why the folded-tab arm is the real measurement for population (a).

Three of my briefing assumptions were falsified, and I verified the load-bearing one myself

A2.2(b) — sub-population (b) is EMPTY, not merely small. I briefed "leaves whose type has no registered designer" as a second population. registerBuiltinPreviews() registers a preview for all five leaf types, so (b) is reachable only in a host registering a partial set. I checked this on main rather than accept it: page, object, dashboard, report, action all registered, plus nine more. The falsification holds. Population (a) is 1 (object) — thin, and reported as thin.

A2.4 — falsified softly, and handled the right way. No test asserts the selection survives, but studioCanvasLeaf.test.tsx's fourth test carried it as a written premise, and two of its three assertions were measuring objectui#7121's gating against a live stale selection. After this fix they hold for a stronger reason. Nothing was weakened or deleted — the comment was corrected and the place objectui#7121 stays independently pinned was named. Deleting those assertions would have been the easy move and would have quietly reduced coverage.

The centerTab mechanism was refined, not confirmed. This was the sub-claim I required be measured or re-reported as NOT MEASURED. It was measured, and the card's description turned out wrong: it is not an auto-switch to Properties. hasInspectorTarget never changes across the leaf walk, so nextCenterTab sees no edge at all and the author is stranded on Properties. Same defect, more precise cause.

And the consequence matters for scope: post-objectui#7121 the folded center tab is the only surviving observable of this leak on population (a), because the rail and clear-button symptoms are already gated off there. A fix validated on the rail symptoms alone would have measured nothing.

Recorded: an observation deliberately not filed

You raised this for my call rather than filing it — centerTab's effect early-returns on !showFoldedTabswithout updating prevInspectorTargetRef, so the ref goes stale across a viewport resize. You traced both directions, found each reachable edge produced a defensible outcome, and did not measure it.

I agree with not filing it. An unmeasured "maybe" in the queue costs a future triage pass more than it saves, and this seat has spent real time on cards whose disqualifier only appeared on a full read. Recording it here instead so it is not lost — that is the right home for a traced-but-unmeasured observation.

Ledger

  • Clause ② not engaged, verified rather than assumed: InterfacesPillar is a module export that packages/app-shell/src/index.ts does not re-export, and no exported type moved.
  • Regression surface: views/studio-design/ 43 files / 230 tests, views/metadata-admin/ 221 files / 2303 tests — includes objectui#7121's own pin, its live fence designerRegistryPartial, objectui#6795 part C's designerRegistryMissing, and centerTab's unit tests.
  • Mutation proven by injected-marker count and blob hash; restore proven by state (git diff HEAD, git diff --cached, git status --short all empty), not by the trap firing.
  • Three comments this change falsified were corrected in place — two in StudioDesignSurface.tsx asserting the leak was still live, one premise note on the objectui#7121 pin.
  • mergeable_state read behind while you worked; that is normal for parallel lanes and the queue rebuilds entries. main has since advanced twice more.

Landing will be verified by content with a live control once main advances — a queue sha is not a landing.


Generated by Claude Code

Merged via the queue into main with commit 0101fabSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7137-selection-leak-non-editable-leaf branch September 1, 2026 06:34
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(app-shell): scope the Interfaces block selection to its leaf - #7145

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7137-selection-leak-non-editable-leaf
Sep 1, 2026
Merged

fix(app-shell): scope the Interfaces block selection to its leaf#7145
os-warren merged 1 commit into
mainfrom
claude/issue-7137-selection-leak-non-editable-leaf

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7137

The Interfaces pillar's block selection outlived a leaf change on every leaf
where isEditable === false, because the only clear sat after the draft-load
effect's early return:

React.useEffect(() => {
if (!current || !isEditable) { setDraft({}); setHasDraft(false); return; }
...
setSelection(null); // never reached on the early-return path
}, [client, current, isEditable, publishNonce]);

isEditable = !!Preview && !StudioCanvas is a conjunction, so that early return
covers two disjoint populations, and the surviving selection then described a
block on the previous leaf's canvas.

Premise re-derived on the current head

Verified in a fresh worktree at origin/main = 0d4c7890d — the commit that
landed #7121, i.e. after the card was filed. The guard's shape and the stranded
clear are both still exactly as the card quotes them. Premise holds.

Populations, measured

populationin-repo countlive symptoms after #7121
(a) studio-canvas leaves1 (object, studio-canvas-preview.tsx:97)folded-layout center tab only
(b) leaf whose own type has no designer0 under the shipped registrationscoped inspector + clear button

The pillar's leaves come from resolveSurface, which can produce exactly five
types (page / object / dashboard / report / action), and
registerBuiltinPreviews() registers a preview for all five. So population (b)
is empty when register-builtins has run. It is reachable only in a host
that registers a partial set — which is the state
StudioDesignSurface.designerRegistryPartial.test.tsx already treats as a
supported product fact, and which this PR's pin constructs.

On population (a) the rail and header symptoms are already unreachable, because
#7121 gates both on StudioCanvas. What that card did not gate is
hasInspectorTarget, which feeds nextCenterTab — so the folded center tab is
the one surviving observable there, and it is measured below.

The repair, chosen by measurement

The selection is now stamped with the leaf it was made on and read back through
that key — the same shape blockingReport already uses against inspectorKey
in this component.

The card offered three candidates. Candidate 1, hoisting setSelection(null)
above the guard, was implemented and measured rather than reasoned about: it
closes the center-tab symptom, but the clear runs in an effect, i.e. one
committed render after the leaf change, so the foreign-block inspector still
mounts. Against the same pin:

  • unfixed tree: ledger [ 'page:home_page:block:blk_1', ...(2) ] — 3 stale renders
  • candidate 1 (hoist): ledger [ 'page:home_page:block:blk_1' ]1 stale render, 1 failed | 4 passed (5)
  • this PR (leaf-keyed): ledger []5 passed (5)

That single surviving render is what decided it. Keying also leaves no
imperative clear for a future guard to strand, which is how this defect arose.

Within a leaf nothing changes: the Design/Run round trip keeps its selection
(#5800's stated contract), and a same-leaf reload via publishNonce still
clears it — both pinned.

Both symptoms are closed, and the card's NOT MEASURED sub-claim is now measured

The card flagged the centerTab symptom as "read from the code path, not
measured in a browser."
It is measured here, in the folded layout with
foldInspector, reading the active tab from the DOM:

  • control on the same instrument: picking a block does drive the tab to
    Properties, so the reading below is of a live auto-switch, not a static strip;
  • unfixed: expected 'Properties' to be 'Canvas' — the stale target suppresses
    the return-to-Canvas edge;
  • fixed: the tab returns to Canvas.

One refinement to the card's wording: the mechanism is not an auto-switch to
Properties on arrival. hasInspectorTarget never changes across the leaf walk,
so nextCenterTab sees no edge at all and the author is stranded on the
Properties tab they were already on. Same defect, same repair, more precise
cause.

Not done here

Verification

All runs below are at db22d18b0, on a clean tree.

  • pnpm exec vitest run .../StudioDesignSurface.selectionLeafScope.test.tsx
    new pin. Before: 3 failed | 2 passed (5). After: 5 passed (5).
    The 2 that passed before are named and explained in the file: one is the
    over-fire fence (green on both sides by design), the other is the
    studio-canvas leaf, green because finding(app-shell): the Interfaces pillar offers Design mode and a "click a block" rail on leaves that have no block canvas — measured with a POPULATED registry #7121 already blocks that path — the
    finding that made the center-tab test the real measurement for population (a).
  • pnpm exec vitest run packages/app-shell/src/views/studio-design/
    43 passed (43) files, 230 passed (230) tests.
  • pnpm exec vitest run packages/app-shell/src/views/metadata-admin/
    221 passed (221) files, 2303 passed | 1 skipped (2304).
  • pnpm --filter '@object-ui/app-shell' type-check — exit 0
    (tsc --noEmit && tsc -p tsconfig.test.json). Confirmed to actually cover the
    new file: --listFiles lists it, alongside a known-covered sibling as control.
  • pnpm --filter '@object-ui/app-shell' lint — exit 0, 0 errors
    (2853 pre-existing warnings package-wide, none in the changed files).
  • check:control-bytes, check:vi-mock-specifiers, check:vi-mock-inherit,
    check:i18n-keys, check:shell-escape-residue — all exit 0.
  • scripts/check-changeset-no-major.mjsNo changeset declares a major bump.

The ablation restore was verified by state, not by the trap firing:
git diff HEAD, git diff --cached and git status --short all empty, and the
worktree blob back to HEAD's f4c98092438320bba6469a3e886d21b2779cc2d8.

Repo-wide pnpm lint and the full gate farm are left to CI, which runs them
once regardless.


Generated by Claude Code

`InterfacesPillar`'s only clear of `selection` sat inside the draft-load
effect, after its `if (!current || !isEditable) … return` guard, so it never
ran on the early-return path. `isEditable = !!Preview && !StudioCanvas` is a
conjunction, so that is two populations of leaf — a studio-canvas leaf, and a
leaf whose own type has no registered designer — and walking to either from a
leaf with a block selected carried the selection across.
Key the selection to the leaf it was made on and read it back through that
key, the same shape `blockingReport` already uses against `inspectorKey`. It
expires in the same render as the leaf change rather than one committed render
later, and leaves no imperative clear for a future guard to strand.
objectui#7121's rail / Design-mode gating is untouched; its discriminator
stays `StudioCanvas`, never `isEditable`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3152.6 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-a5lwjhZN.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)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
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)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.25KB61.73KB
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.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.61KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.31KB54.66KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.19KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
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)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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-warren
os-warren marked this pull request as ready for review September 1, 2026 06:20
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Reviewed and armed

All 30 check runs read in one call at perPage=100: 27 success, 3 skipped, 0 failed, 0 in_progress. Undrafted, auto-merge SQUASH enabled at db22d18b0.

The reason this card went well: my suggested route was tested, not obeyed

I put inspectorKey-style leaf-keying forward as a weak preference in zone 3, and said to pick by measurement. That is exactly what happened — and it is worth being precise about, because "the PM's preference won" and "the PM's preference was measured to be better" look identical in a diff and are not the same thing.

Candidate 1 (hoist setSelection(null) above the guard) was built from the base blob and run against the same pin:

repairpin resultstale-selection ledger
unfixed3 failed | 2 passed (5)3 stale committed renders
candidate 1 — the hoist1 failed | 4 passed (5)1
landed — leaf-keyed5 passed (5)0

One frame decided it. Direction predicted before running. The hoist looked equivalent by reading and was not, and nothing but building both would have shown that.

Equally good: the two green arms in the red baseline were named and explained, not left as survivors. does not carry the selection onto a studio-canvas leaf passes on both sides because objectui#7121 already orders the studio-canvas rail branch ahead of the selection branch — which is the finding, and why the folded-tab arm is the real measurement for population (a).

Three of my briefing assumptions were falsified, and I verified the load-bearing one myself

A2.2(b) — sub-population (b) is EMPTY, not merely small. I briefed "leaves whose type has no registered designer" as a second population. registerBuiltinPreviews() registers a preview for all five leaf types, so (b) is reachable only in a host registering a partial set. I checked this on main rather than accept it: page, object, dashboard, report, action all registered, plus nine more. The falsification holds. Population (a) is 1 (object) — thin, and reported as thin.

A2.4 — falsified softly, and handled the right way. No test asserts the selection survives, but studioCanvasLeaf.test.tsx's fourth test carried it as a written premise, and two of its three assertions were measuring objectui#7121's gating against a live stale selection. After this fix they hold for a stronger reason. Nothing was weakened or deleted — the comment was corrected and the place objectui#7121 stays independently pinned was named. Deleting those assertions would have been the easy move and would have quietly reduced coverage.

The centerTab mechanism was refined, not confirmed. This was the sub-claim I required be measured or re-reported as NOT MEASURED. It was measured, and the card's description turned out wrong: it is not an auto-switch to Properties. hasInspectorTarget never changes across the leaf walk, so nextCenterTab sees no edge at all and the author is stranded on Properties. Same defect, more precise cause.

And the consequence matters for scope: post-objectui#7121 the folded center tab is the only surviving observable of this leak on population (a), because the rail and clear-button symptoms are already gated off there. A fix validated on the rail symptoms alone would have measured nothing.

Recorded: an observation deliberately not filed

You raised this for my call rather than filing it — centerTab's effect early-returns on !showFoldedTabswithout updating prevInspectorTargetRef, so the ref goes stale across a viewport resize. You traced both directions, found each reachable edge produced a defensible outcome, and did not measure it.

I agree with not filing it. An unmeasured "maybe" in the queue costs a future triage pass more than it saves, and this seat has spent real time on cards whose disqualifier only appeared on a full read. Recording it here instead so it is not lost — that is the right home for a traced-but-unmeasured observation.

Ledger

  • Clause ② not engaged, verified rather than assumed: InterfacesPillar is a module export that packages/app-shell/src/index.ts does not re-export, and no exported type moved.
  • Regression surface: views/studio-design/ 43 files / 230 tests, views/metadata-admin/ 221 files / 2303 tests — includes objectui#7121's own pin, its live fence designerRegistryPartial, objectui#6795 part C's designerRegistryMissing, and centerTab's unit tests.
  • Mutation proven by injected-marker count and blob hash; restore proven by state (git diff HEAD, git diff --cached, git status --short all empty), not by the trap firing.
  • Three comments this change falsified were corrected in place — two in StudioDesignSurface.tsx asserting the leak was still live, one premise note on the objectui#7121 pin.
  • mergeable_state read behind while you worked; that is normal for parallel lanes and the queue rebuilds entries. main has since advanced twice more.

Landing will be verified by content with a live control once main advances — a queue sha is not a landing.


Generated by Claude Code

Merged via the queue into main with commit 0101fabSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7137-selection-leak-non-editable-leaf branch September 1, 2026 06:34
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@os-warren@claude