Skip to content

fix(console): one writer owns the tab title after an in-app navigation (objectui#8637) - #10034

Merged
os-tesla merged 3 commits into
mainfrom
claude/issue-8637-tab-title-one-writer
Sep 19, 2026
Merged

os-tesla merged 3 commits into
mainfrom
claude/issue-8637-tab-title-one-writer

Conversation

@os-tesla

Copy link
Copy Markdown
Collaborator

Fixes #8637

What was wrong, and how it was measured

Two effects wrote document.title on different keys.

  • apps/console/src/App.tsx rendered BrandingSync, a sibling mounted before the shell inside BrowserRouter, whose effect was keyed on useLocation() and assigned the bare product name on every route change.
  • packages/layout/src/AppShell.tsxuseAppShellBranding assigns the composed "App label — Product name" from an effect whose dependency list ends in that string, so it fires when the title changes and not on navigation. ConsoleLayout is what composes that string.

Both fire on the commit that mounts the shell, in tree order, and the composed title wins — which is why the tab looks right and the defect stays hidden. An in-app navigation moves location and not the composed title, so only the route-keyed writer runs.

The card's PM note ruled that reading the code does not settle this and that happy-dom does not either, so the reading below is real Chromium (the preinstalled /opt/pw-browsers/chromium, driven by the repo's own Playwright), on the real BrandingSync/FaviconSync and the real AppShell, under a real BrowserRouter with clicks on real router links. document.title's accessor was instrumented so every write is recorded in order; without a backend getProductName() is its default, ObjectOS.

Before — 030a675b0 plus the verbatim extraction commit:

step 0  after boot, before entering the app   title="ObjectOS"              writes=[]
step 1  enter the app (/probe/a)              title="Sales CRM — ObjectOS"  writes=["ObjectOS","Sales CRM — ObjectOS","Sales CRM — ObjectOS"]
step 2  IN-APP NAVIGATION (/probe/a → /b)     title="ObjectOS"              writes=["ObjectOS"]
step 3  leave the app (/outside)              title="ObjectOS"              writes=["ObjectOS"]
step 4  re-enter the app (/probe/a)           title="Sales CRM — ObjectOS"  writes=["ObjectOS","Sales CRM — ObjectOS","Sales CRM — ObjectOS"]

Step 2 is the defect: a single write, the bare product name, over the specific title. Step 1 shows why it hides — the composed title wins the mount. Step 3 is the part a static reading does not give you, and it decided the shape of the fix: the route-keyed writer was also the reset that took the app label back off the tab when the shell went away.

After — this branch:

step 0  after boot, before entering the app   title="ObjectOS"              writes=[]
step 1  enter the app (/probe/a)              title="Sales CRM — ObjectOS"  writes=["Sales CRM — ObjectOS","ObjectOS","Sales CRM — ObjectOS"]
step 2  IN-APP NAVIGATION (/probe/a → /b)     title="Sales CRM — ObjectOS"  writes=[]
step 3  leave the app (/outside)              title="ObjectOS"              writes=["ObjectOS"]
step 4  re-enter the app (/probe/a)           title="Sales CRM — ObjectOS"  writes=["Sales CRM — ObjectOS","ObjectOS","Sales CRM — ObjectOS"]

Step 2 is now zero writes — nothing touches the tab title on an in-app navigation at all. Step 3 is byte-for-byte what it was before, and it is now the shell's own restore rather than a route-keyed reset. The set-restore-set in steps 1 and 4 is StrictMode double-invoking the effect; the capture is taken inside the effect, so it is idempotent.

The probe page and its driver were temporary and are not in this diff.

The fix — one writer, not two careful ones

Triage asked for one writer and said the other should stop writing document.title at all rather than writing it carefully. app-shell-branding-title-assignment.test.tsx already decided which one survives, so the writer that stops is the console's.

  1. useAppShellBranding now owns document.title for as long as a shell is mounted. It captures whatever the tab already said, writes title over it, and puts the capture back when the shell unmounts or title changes. previousTitle stays null when the hook writes nothing, so a shell with no title still leaves the tab alone in both directions.
  2. The console component drops its title assignment entirely and is renamed FaviconSync, which is all it does now. It stays keyed on the route, and its docblock says why a title assignment must not come back.
  3. BrandingSync was first extracted from App.tsx verbatim, in its own commit, so the probe and the pin render the component the console actually ships instead of a replica of it. A replica would pin the replica and let the defect walk back in through the real component.

Nothing about the forward assignment changed: still wholesale, still no concatenation.

The pin the card asked for

From the card: "the reason this survived is that no test asserts what the tab title is after a navigation. A fix without that pin re-opens the same hole."

  • apps/console/src/__tests__/tabTitleAfterNavigation.test.tsx — renders the real FaviconSync beside the real AppShell, clicks a router link to a second page of the same app, and asserts the tab still reads the composed title. Also pins that leaving the app hands the title back.
  • packages/layout/src/__tests__/app-shell-branding-title-restore.test.tsx — the restore half, at the package that owns it, including the documented cost: the restore replays the captured string unconditionally, so a surface that writes the tab title from inside a mounted shell is overwritten on unmount.

Both files open with an environment control, so a vacuous green in a DOM with a no-op title setter is distinguishable from a real one.

Control reading. Re-adding the route-keyed title assignment to FaviconSync — mutation proved on disk by an occurrence count and a git hash-object comparison against the HEAD blob, then restored, with git diff HEAD verified empty:

document.title writes in FaviconSync: 0 -> 1
HEAD blob=298b1fa4af4d565f2e015e0ffa2c7f33ac3fd5e1  mutated blob=86f5938494f84cecc3823dc91a55ff16ad0cca14

Test Files  1 failed (1)
     Tests  2 failed | 3 passed (5)
  × navigating to another page of the SAME app leaves the composed title alone
  × navigating a third time still leaves it alone — the effect is not merely deferred
AssertionError: ... expected 'ObjectOS' to be 'Sales CRM — ObjectOS'

The two navigation assertions fail and the other three pass — the pin fails for the reason it exists, not for an unrelated one. Removing the restore from AppShell.tsx instead (previousTitle occurrences 5 -> 1, blobs differ, restored clean) turns 6 assertions red across three files.

One existing pin was changed, and it is stricter afterwards

app-shell-branding-title-surfaces.test.ts asserted that AppShell.tsx contains exactly one document.title writer, so a second one could not appear without the wording pin noticing. The restore is a second source write, and that assertion went red — the pin doing its job.

What was kept: the assigning writer is still exactly one, its operator is still =, its right-hand side is still the bare title, and the total is still an exact count, so a third writer still fails. What was added: the restore's own right-hand side is now pinned too, so the cleanup cannot start composing a title either. The docblock records that this began as "exactly one writer" and why it is now two-by-role.

app-shell-branding-title-assignment.test.tsx is untouched — it is not in this diff — and green. It is also the pin that decided this card's direction, so it is worth saying that it passes identically on the defect and on the fix: it never navigates, which is exactly the hole this PR fills.

Validation

All run from the repo root with pnpm exec vitest run PATH; the heavy runs went through the shared verify lock in ../objectstack.

  • pnpm exec vitest run packages/layout/ apps/console/ — 124 files, 1387 tests passed, exit 0.
  • pnpm exec vitest run packages/app-shell/ — the direct consumer that mounts AppShell through ConsoleLayout: 734 files, 7274 passed, 1 skipped, exit 0.
  • turbo run type-check lint --filter=@object-ui/layout --filter=@object-ui/console — 40/40 tasks, exit 0. Lint is warnings-only on both packages and the counts are the pre-existing ones.
  • Gates: check:changeset-presence (green, names this changeset), check:new-line-citations, check:control-bytes, check:test-path-roots, check:unreferenced-sources, check:changeset-claims, check:pending-changeset-literals, check:vi-mock-specifiers — all exit 0.
  • node scripts/check-governed-queue-guard.mjs --test over this diff's 7 paths: not governed, no surface matched.

Repo-wide pnpm lint and the full pnpm test are CI's run, not narrowed here.

Scope

The card's own warning is that "one writer" is not literally achievable, so here is each remaining writer and what was done about it.

  • apps/console/index.html, inline boot script — writes the bare product name during parse, before React exists. Left alone. Different lifecycle; it is the correct title until a shell with an app label is on screen, and it is now the string the shell captures and restores.
  • apps/console/src/main.tsx — same, immediately before createRoot().render(). Left alone, same reasoning. The pair is objectui#5544's subject, not this card's.
  • apps/console/src/pages/auth/AuthLayout.tsx — already save-set-restore for its host pill, scoped to its own mount. Left alone; it is the same discipline this PR gives the shell, one lifecycle down, and the console's auth routes sit outside ConsoleLayout so the two never nest.
  • apps/console/src/App.tsx / FaviconSync — the one that stopped writing.

Out of the fence and untouched: pnpm-lock.yaml, packages/app-shell/package.json, packages/plugin-kanban, and machine-locale Intl sites.

Acceptance notes

Noted while in these files, not filed, no measurement taken:

  • The favicon has the same asymmetry the title used to have. useAppShellBranding sets branding.favicon while mounted and does not restore it; what puts the runtime favicon back today is FaviconSync's route-keyed re-apply. That re-apply is a no-op when getFaviconUrl() is falsy, i.e. when no operator favicon is configured — in which case a branded app's favicon would outlive the app. Stated as the condition that would make it a defect, not as a measured one: no browser reading was taken for the favicon path. Whoever next touches useAppShellBranding is looking straight at it.

Landing is the dispatching seat's act: this PR stays draft, is not enqueued and has no auto-merge.

Implemented from session session_018HrVaotisyhgmot9o2MLRq.

🤖 Generated with Claude Code

https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq


Generated by Claude Code

No behaviour change — the component body, its dependency list and its
placement inside `BrowserRouter` are carried over verbatim. Pulling it out
of `App.tsx` gives the route-keyed `document.title` writer an importable
name, so a browser probe and a test can render it beside `AppShell`'s
writer instead of a hand-written replica of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq
`BrandingSync` assigned the bare product name to `document.title` from an
effect keyed on `useLocation()`, while `useAppShellBranding` assigns the
composed "App label — Product name" from an effect keyed on that string.
Both fire on the commit that mounts the shell and the composed title wins,
so the tab looks right; an in-app navigation moves `location` and not the
composed title, so only the route-keyed writer runs and the tab falls back
to the bare product name. Reproduced in real Chromium against these two
components under a real `BrowserRouter` before this change, and again after.

`useAppShellBranding` now owns the title while a shell is mounted: it
captures the current title, writes `title` over it, and restores the capture
on unmount or when `title` changes. That is what makes one writer enough —
the route-keyed write had doubled as the reset that took the app label off
the tab when the shell went away. The console component keeps only its
favicon sync and is renamed `FaviconSync` to say so.

Pins: `tabTitleAfterNavigation.test.tsx` renders the real `FaviconSync`
beside the real `AppShell` and navigates, which is the assertion the card
says was missing; `app-shell-branding-title-restore.test.tsx` pins the
restore half. The source-level writer pin in
`app-shell-branding-title-surfaces.test.ts` failed on the restore, as it
should have — it now pins both writers by role and right-hand side, keeping
its exact count and adding the restore's RHS rather than relaxing anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq
The first draft reached the router through a module-scoped handle assigned
from inside a component, which `react-hooks/react-compiler` rejects outright
("Cannot reassign variables declared outside of the component/hook") — the
console's lint run was the only red in the targeted gates. Clicking a `<Link>`
is both lint-clean and closer to what the console actually does when the
sidebar moves between pages of an app.

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

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 329 chunks) 3056.6 KB 3104.5 KB
Main entry chunk (gzip) 145.8 KB 350 KB
Entry file index-Cf1-Sm1a.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.69KB 6.21KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 545.92KB 130.72KB
core (index.js) 8.94KB 3.59KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 216.90KB 60.15KB
fields (index.js) 249.62KB 63.02KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.22KB 2.26KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 32.15KB 10.49KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.90KB 10.97KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 14.81KB 3.63KB
plugin-calendar (index.js) 50.26KB 14.36KB
plugin-charts (index.js) 71.73KB 20.08KB
plugin-chatbot (index.js) 198.20KB 47.14KB
plugin-dashboard (index.js) 132.96KB 35.17KB
plugin-designer (index.js) 215.94KB 44.33KB
plugin-detail (index.js) 255.18KB 66.49KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 139.56KB 35.40KB
plugin-gantt (index.js) 167.62KB 41.26KB
plugin-grid (index.js) 213.44KB 58.21KB
plugin-kanban (index.js) 48.71KB 15.17KB
plugin-list (index.js) 113.55KB 27.99KB
plugin-map (index.js) 21.48KB 6.99KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.41KB 11.93KB
plugin-timeline (index.js) 30.68KB 8.95KB
plugin-tree (index.js) 10.58KB 3.72KB
plugin-view (index.js) 85.18KB 21.05KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 109.04KB 36.08KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.38KB 1.98KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.74KB 2.54KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 15.71KB 5.30KB
types (ai.js) 4.11KB 2.06KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.04KB 5.36KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-tesla
os-tesla marked this pull request as ready for review September 19, 2026 18:49
@os-tesla
os-tesla enabled auto-merge September 19, 2026 18:49
@os-tesla
os-tesla added this pull request to the merge queue Sep 19, 2026
Merged via the queue into main with commit 0febe1a Sep 19, 2026
38 checks passed
@os-tesla
os-tesla deleted the claude/issue-8637-tab-title-one-writer branch September 19, 2026 19:25
os-tesla pushed a commit that referenced this pull request Sep 19, 2026
…sories

objectui#8637's PR #10034 landed while this branch was reporting, so the base
moved. Merge rather than rebase: this branch is pushed and may be checked out
elsewhere, and the repo's convention is that history on a pushed branch is
never rewritten. The squash merge drops this commit from main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants