Skip to content

feat(app-shell,i18n): announce inbox arrivals with a toast and a desktop notification (#7011) - #8668

Merged
os-justin merged 2 commits into
mainfrom
claude/issue-7011-inbox-toast-and-desktop-notification
Sep 9, 2026
Merged

feat(app-shell,i18n): announce inbox arrivals with a toast and a desktop notification (#7011)#8668
os-justin merged 2 commits into
mainfrom
claude/issue-7011-inbox-toast-and-desktop-notification

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes #7011

The inbox was completely silent about arrivals. sharedUserFeeds polls sys_inbox_message every 10s, the rows landed in the store, the bell badge counted them — and a user not staring at the bell learned nothing, so approvals and @-mentions were routinely missed. All three candidate popup paths existed and none was connected: the feed had no diff logic, the console's sonner bridge only serves notifications that explicitly declare displayType: 'toast', and there was no new Notification( call anywhere in packages/ or apps/.

⛔ Transport untouched

Presentation layer only. Same two reads, same 10s / 60s cadence, same failure backoff, no WebSocket / SSE / long-poll. The accepted consequence is that a backgrounded tab can be up to a minute late — speeding the poll up to shave that would trade a server-wide cost for one surface's latency. git diff touches no file under hooks/sharedUserFeeds.ts's scheduling, and none of its cadence constants moved.

What lands

Module Role
hooks/inboxArrivals.ts The pure diff: a session-scoped seen set, (topic, title) collapse reused from the inbox's own groupNotifications, bounded memory
hooks/useInboxArrivalNotifier.ts The presenter, mounted from useInboxBell
hooks/desktopNotifications.ts The single door to the browser Notification API
hooks/notificationPreferences.ts The two switches, one live value per tab
layout/NotificationPreferencesMenu.tsx Those switches in the account menu's Preferences section

Mounted from useInboxBell rather than from AppHeader, because that hook is the ONE wiring of the shared feed onto a bell: the header bell and the global:notifications page block then announce by the same rules and through the same markRead. Mounting both at once is safe by construction — the seen set is module-scoped, so the first scan of a snapshot takes its arrivals and the second finds none.

The toast entry point was re-located by symbol

The card named presentNotificationToast and triage could not find it in source, flagging it as unverified. It does exist: packages/app-shell/src/chrome/notificationToast.tsx, exported from chrome/index.ts. It is used as-is — that module's own contract is "the ONLY place a notification becomes a sonner call", so the announcement goes through it rather than around it.

The five constraints

  1. First fetch does not pop. The first ready snapshot for a session identity primes the seen set and announces nothing; historical unread at login or refresh updates the badge only. Only ready is scanned — a loading / error snapshot carries the last value, and priming off one would either swallow the inbox or announce all of it.
  2. Merge within a cycle. One announcement per cycle, worded by the inbox's own (topic, title) collapse: one group says its own title, several groups summarize.
  3. ⚠️ Self-triggered does not pop — NOT IMPLEMENTED, and it cannot be from here. sys_inbox_message carries no actor column at all (inbox-channel.ts writes the row field by field and includes none; listInbox's REST view has none either). The actor exists only one FK hop up on sys_notification.actor_id, and the shipped permission set does not grant a normal user read on that object. This repo has also already ruled the consumer side cannot go first: objectui#5203 retired InboxNotification.actor_name for exactly this reason and left two pins forbidding its re-declaration, one of which asserts the produced row's exact key set. Filed rather than guessed — messaging: sys_inbox_message 不带 actor,消费端无法识别「自己触发的回执」 objectstack#16974 (add the column at materialization) and app-shell: 站内信到达提醒缺「自己触发的不弹」—— 等收件箱行带上 actor #8667 (Blocked-by: it, wire the suppression, ~4 lines).
  4. Permission is never requested on load. Notification.requestPermission() is reachable only from the settings toggle's change handler.
  5. Toast and desktop notification are mutually exclusive. document.visibilityState === 'visible' decides, in one place.

⭐ The non-regression axis, and its lit control

The plausible wrong fix presents correctly but asks for permission at startup to make the desktop path work. A browser answers that prompt once and denied is permanent for the origin, so that fix spends the channel for every user who reflexively blocks and no later release undoes it.

Pinned in both directions, because a counter that only ever reads zero also passes on an implementation that deleted the call:

  • useInboxArrivalNotifier.permission case — not on mount, not on a feed refresh, not on the first message, visible or hidden. Verified RED by moving the call to mount (leg A5).
  • NotificationPreferencesMenu case — the toggle does prompt, exactly once.
  • Both again in a real browser, in two separate contexts (below).

Verification

Unit — 42 new pins, all seven ablation legs RED. Every mutation was proved on disk by anchored grep counts plus a blob-hash comparison (never an editor's exit code), and every restore proved by git diff HEAD being empty. Classified from vitest's JSON reporter.

Leg Mutation Result
A1 drop the priming branch — "announce everything" 5 RED
A2 always return no arrivals — "announce nothing" 17 RED, including the lit instrument control
A3 drop the !is_read half of what an arrival is 2 RED
A4 remove the visibility split 5 RED
A5 ⭐ request permission on mount 1 RED (the permission pin)
A6 announce once per row instead of per cycle 3 RED
A7 stop notifying preference readers (the pre-fix shape) 3 RED

The caricature is pinned in both directions, and "pops for none" does not read as success: A2 turns the harness's own lit control (the Notification stub is live) red along with the positive cases.

Suites: pnpm exec vitest run packages/i18n/ packages/app-shell/src/hooks/ packages/app-shell/src/layout/ packages/app-shell/src/views/__tests__/global-page-blocks.render.test.tsx packages/app-shell/src/console/home/149 files, 1746 tests passed. pnpm --filter @object-ui/app-shell run type-check — 0 error TS (it runs tsc --noEmit and tsc -p tsconfig.test.json, so the new pins are type-checked too). eslint on every changed file — 0 errors. The full affected closure (i18n is depended on by ~25 packages) is declared to CI rather than run here.

Real browser — 20/20, and it found a bug the unit pins could not. happy-dom implements neither Notification nor a settable document.visibilityState, so the unit pins measure a simulation of both. apps/console/inbox-arrival-preview.html (dev-server only, like the five sibling *-preview.html pages — the production build's single rollup input is index.html) mounts the real presenter, the real sonner toaster and the real preferences menu; scripts/inbox-arrival-browser-check.mjs drives it in Chromium across two contexts, one with notifications granted and one without. This is the test instance kept as a fixture.

It caught this: the settings menu and the presenter each held their own useState copy of the preferences, so switching desktop notifications on did nothing until the page was reloaded — the switch said granted, the presenter still believed desktop: false, the tab stayed silent. useStorageSync could not cover it, because the storage event fires only in other tabs by design. Both surfaces now read one module-scoped store, and leg A7 reddens the new pin on the old shape.

The card's examples/app-showcase route was not taken: that app lives in the framework repo and needs a live backend, and the repo's own verify skill covers the metadata-admin preview gallery, which does not mount the shell chrome. Reported rather than skipped silently.

Notes for review

  • Seven notifications.* keys added to all ten locale packs; check-i18n-call-site-keys and check-i18n-en-drift both green.
  • Changeset verdict: ✅ 12 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s). minor, per the fixed-group rule.
  • useInboxBell now also returns the feed's status (additive) — the presenter may only scan an answer.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S


Generated by Claude Code

…top notification (objectui#7011)

The inbox was completely silent about arrivals: `sharedUserFeeds` polls
`sys_inbox_message`, the rows landed in the store, the bell badge counted them,
and a user not staring at the bell learned nothing — so approvals and @-mentions
were routinely missed.

Presentation layer only. The transport is untouched: same two reads, same
10s/60s cadence, same backoff, no push channel. A backgrounded tab can therefore
be up to a minute late, which is accepted rather than worked around.

- `inboxArrivals` — the pure diff: a session-scoped seen set, `(topic, title)`
  collapse reused from the inbox's own `groupNotifications`, bounded memory.
- `useInboxArrivalNotifier` — mounted from `useInboxBell`, the one wiring of the
  shared feed onto a bell, so the header bell and the `global:notifications`
  block announce by the same rules and through the same `markRead`.
- `desktopNotifications` — the single door to the browser Notification API.
- `NotificationPreferencesMenu` — two switches in the account menu, stored per
  user in localStorage: in-app alerts (on), desktop notifications (off).

The negative rules are the load-bearing ones: the first answered read primes and
announces nothing, several rows in one cycle announce once, an already-read row
never announces, and a hidden tab gets the desktop notification instead of the
toast (never both).

`Notification.requestPermission()` is called from the settings toggle's change
handler and from nowhere else — never on mount, on a feed refresh or on a first
message. That prompt is answered once per origin and `denied` is permanent, so a
load-time request spends the channel for every user who reflexively blocks.

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

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

ℹ️ Console Performance Budget — not measured

This run did not produce a console bundle to measure, so there is no pass/fail verdict for the performance budget.

This is not a budget violation. Nothing was measured — the numbers a real violation would carry are simply absent.

Step Outcome
Build packages success
Check console performance budget skipped

See the workflow run for details.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 15.67KB 5.75KB
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) 498.93KB 114.12KB
core (index.js) 7.48KB 2.96KB
create-plugin (index.js) 10.12KB 3.28KB
data-objectstack (index.js) 198.39KB 55.29KB
fields (index.js) 243.73KB 61.53KB
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) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
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.84KB 10.94KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 15.16KB 3.68KB
plugin-calendar (index.js) 49.00KB 13.91KB
plugin-charts (index.js) 71.39KB 19.92KB
plugin-chatbot (index.js) 194.53KB 46.34KB
plugin-dashboard (index.js) 131.43KB 34.44KB
plugin-designer (index.js) 213.21KB 43.63KB
plugin-detail (index.js) 251.25KB 65.00KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 131.01KB 32.32KB
plugin-gantt (index.js) 167.16KB 40.99KB
plugin-grid (index.js) 208.30KB 56.63KB
plugin-kanban (index.js) 55.44KB 15.73KB
plugin-list (index.js) 112.73KB 27.69KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.33KB 3.25KB
plugin-view (index.js) 84.54KB 20.84KB
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) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.55KB 2.45KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 13.64KB 4.59KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (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

… and repair the fixture's imports (objectui#7011)

Two CI reds on PR #8668, both this branch's own.

`check:spec-symbols` — `@objectstack/spec/api` already owns
`NotificationPreferences`, and it is a different layer: the account's
server-persisted delivery routing (`email`, `push`, `inApp`, `digest`,
`channels`), moved by the `getNotificationPreferences` API pair. The local
interface is this browser's presentation switches (`toast`, `desktop`). Zero
keys in common, and the spec's schema strips both of ours, so importing or
deriving it cannot express the two switches — it would change what the feature
stores. Renamed to `BrowserNotificationPreferences`, with the measurement in the
declaration's doc comment and a tripwire row in `spec-symbol-parity.test.ts` so
the new name cannot silently re-collide.

`Bundle Analysis` — the job never reached the bundle. The console build failed
with six TS2307s: the browser fixture imported `@object-ui/app-shell/hooks/…`
and `@object-ui/auth/AuthContext`, and neither package publishes a subpath. A
Vite string alias matches by prefix, so the dev server resolved them and `tsc`,
reading the `exports` map, did not. The fixture now imports the workspace
sources those aliases already resolved to; nothing is added to either package's
published surface. `apps/console/tsconfig.json` gains `node` in `types` for the
`typeof process` guard in the app-shell source this pulls into the program.

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

Copy link
Copy Markdown
Collaborator Author

CI repair — both reds diagnosed and repaired

Pushed 5076ef69c on this branch. Reproduced each failure locally on a fully built tree first, then repaired, then re-measured. Runtime behaviour of the feature is unchanged; the 44 pins and the real-browser check are still green.


1. Type Checkcheck:spec-symbols

Reproduced. node scripts/check-spec-symbol-derivation.mjs exit 1, naming interface NotificationPreferences at packages/app-shell/src/hooks/notificationPreferences.ts:51.

This was a real finding, so it was measured before it was named. The spec symbol is on the /api subpath (the root export is thin — NotificationPreferencesSchema is undefined there, which is a specifier fact, not an absence). Read off the installed @objectstack/spec 17.3.0:

keys
@objectstack/spec/api NotificationPreferences email, push, inApp, digest, channels
this branch's local interface toast, desktop

Zero keys in common, and they are two different layers under one word:

  • The spec's is the account's server-persisted delivery routing — which transports a notification is sent over and how often. It has a server API pair beside it (getNotificationPreferences / updateNotificationPreferences) and RegisterDevice / UnregisterDevice nearby.
  • This one is this browser's presentation of a row that has already been delivered: may a toast cover this screen, may this browser's Notification API be used.

The direction that settles it is not the key count but the parse:

NotificationPreferencesSchema.parse({ toast: true, desktop: false })
  -> { email: true, push: true, inApp: true, digest: 'none' }

The spec's schema strips both of our keys. So importing or deriving it cannot express the two switches at all — binding to it would change what the feature stores, not merely what the type is called. The doctrine's preferred arm (import/derive) is genuinely unavailable here.

Repair — rename to a dialect, with the tripwire. NotificationPreferences -> BrowserNotificationPreferences, confined to that one file (the type had no consumer outside it; the exported functions, the const and the localStorage key are untouched, so nothing stored or read moved). No ALLOW entry: the collision is gone rather than excused. The measurement above is written into the declaration's doc comment so the next reader does not have to re-derive it.

The tripwire is a row in the existing packages/app-shell/src/__tests__/spec-symbol-parity.test.ts RENAMES table, which arms both ratchets: the spec must not own BrowserNotificationPreferences, and the spec must still own NotificationPreferences — so if upstream ever retires the name, the rename stops being load-bearing and this fails rather than outliving its reason.

The tripwire was observed red, not merely written: swapping the row's local name to the spec-owned NotificationPreferences on disk (blob aa85daf -> f0ed71a) turns exactly that assertion red —

× the spec does not own `NotificationPreferences`
AssertionError: @objectstack/spec now exports `NotificationPreferences`. ... expected true to be false
Tests  1 failed | 31 passed (32)

— and the restore is byte-identical (git hash-object back to aa85daf, git diff HEAD empty).

The header census (twenty-eight / twenty / eight) was not re-counted in place: it is a measurement of the batch-3 burn-down, and a note records that later arrivals are appended instead.

Verified: check:spec-symbols exit 0 — 1361 files scanned against 5050 spec export names; 17 declared dialects, 14 untriaged collisions in 7 packages.


2. Bundle Analysis

The ceiling hypothesis is falsified — the job never reached the bundle. The log ends in the Build Console step, not the budget step, and the budget step's own env shows BUDGET_STEP_OUTCOME: skipped with every BUDGET_* variable empty (Rendered performance budget comment (kind: not-measured)).

The real failure is six TS2307s from apps/console/src/inbox-arrival-preview.tsx, i.e. the console build's tsc:

Cannot find module '@object-ui/auth/AuthContext'
Cannot find module '@object-ui/app-shell/hooks/useInboxArrivalNotifier'
Cannot find module '@object-ui/app-shell/hooks/inboxArrivals'
Cannot find module '@object-ui/app-shell/layout/NotificationPreferencesMenu'
Cannot find module '@object-ui/app-shell/layout/inboxGrouping'
Cannot find module '@object-ui/app-shell/hooks/sharedUserFeeds'

Root cause. Neither package publishes a subpath — . is the whole exports map for both @object-ui/app-shell and @object-ui/auth. The browser fixture looked fine locally because a Vite string alias matches by prefix: @object-ui/app-shell/hooks/x resolved through the @object-ui/app-shell -> packages/app-shell/src alias in apps/console/vite.config.ts, so the dev server and the browser check were both green. tsc resolves the same specifier through the exports map instead, finds no subpath, and fails. That is exactly the check nobody could run before pushing (it needs a built tree), so this is a precondition gap, not negligence.

Repair. The fixture now imports the workspace sources that alias already resolved to (../../../packages/app-shell/src/hooks/...), so the modules loaded at runtime are the same objects as before. Two arms were rejected on purpose:

  • Adding the names to the barrels would publish six internals permanently — including __resetInboxArrivals, which is a declared test seam. Widening two packages' public API so a dev-only fixture can pretend to be a consumer is the wrong trade.
  • tsconfig paths mapping the subpath specifiers would teach that @object-ui/app-shell/hooks/x is importable when it is not — a planted premise for the next reader.

One config line follows from this: apps/console/tsconfig.json gains node in types. The app-shell source this pulls into the console's type program guards on typeof process for the non-browser case, and app-shell's own tsconfig declares types: ["node", "vite/client"]. Without it that legitimate guard is TS2591. It is additive — it declares globals, it suppresses no check. Measured coupling: 30 workspace source files enter the console's program (27 app-shell, 3 auth) out of 3712 total.

And the ceiling, now that it was actually measured. node scripts/check-eager-closure-budget.mjs exit 0 on the built tree, with this branch's 7 keys across all ten locale packs already in:

✅ Console eager closure is 3480.4 KB gzipped across 50 of 518 chunks (budget: 3512.7 KB, headroom: 32.3 KB).
  ✅ vendor-objectstack      1206.4 KB / 1224.6 KB ceiling (headroom 18.2 KB)
  ✅ i18n-locales             442.9 KB / 444.3 KB ceiling (headroom  1.4 KB)
  ✅ ui-components            384.1 KB / 389.6 KB ceiling (headroom  5.5 KB)
  ✅ framework                 70.6 KB /  97.7 KB ceiling (headroom 27.1 KB)

i18n-locales moved 441.5 -> 442.9 KB: the ten packs cost 1.4 KB of the 2.8 KB that was there, and the chunk still fits. PER_CHUNK_GZIP_CEILINGS is untouched. Worth saying out loud anyway: that chunk now has 1.4 KB of headroom, 0.02x the 89 KB regression this gate exists to catch, so the next locale-touching change is likely to be the one that has the objectui#8542 conversation. That is a maintainer call, not this branch's.


What was run

check result
node scripts/check-spec-symbol-derivation.mjs exit 0 (was exit 1)
apps/console tsc --noEmit exit 0, 0 errors (was exit 2, 6 errors)
pnpm --filter @object-ui/console build exit 0 (tsc && vite build && build:plugin)
node scripts/check-eager-closure-budget.mjs exit 0, all four per-chunk ceilings green
pnpm check:sdui-registration-pins exit 0, 16/16 registrations present
pnpm --filter @object-ui/app-shell type-check exit 0 (tsc --noEmit && tsc -p tsconfig.test.json)
pnpm exec vitest run packages/app-shell/ packages/i18n/ 724 files, 7477 passed, 1 skipped (4 shards, all exit 0)
pnpm exec vitest run apps/console/ 93 files, 1102 passed
node scripts/inbox-arrival-browser-check.mjs 20/20 checks passed in real Chromium
node scripts/check-changeset-presence.mjs exit 0
node scripts/check-type-check-coverage.mjs exit 0
eslint on the changed files 0 errors (2 pre-existing react-refresh warnings)

The real-browser check passing 20/20 after the import rewrite is the load-bearing one for "no behaviour change": it drives the same modules through a real Notification grant and a real visibilityState, and every acceptance row still reads the same.

Nothing was skipped, disabled or quarantined; no empty commit; no ceiling raised; draft and auto-merge state untouched.


Generated by Claude Code

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3480.5 KB 3512.7 KB
Main entry chunk (gzip) 144.0 KB 350 KB
Entry file index-CHPJ7UTd.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) 15.67KB 5.75KB
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) 499.42KB 114.32KB
core (index.js) 7.48KB 2.96KB
create-plugin (index.js) 10.12KB 3.28KB
data-objectstack (index.js) 198.39KB 55.29KB
fields (index.js) 244.36KB 61.61KB
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) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
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.84KB 10.94KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 15.16KB 3.68KB
plugin-calendar (index.js) 49.00KB 13.91KB
plugin-charts (index.js) 71.39KB 19.92KB
plugin-chatbot (index.js) 194.53KB 46.34KB
plugin-dashboard (index.js) 131.43KB 34.44KB
plugin-designer (index.js) 215.51KB 44.29KB
plugin-detail (index.js) 251.25KB 65.00KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 131.01KB 32.32KB
plugin-gantt (index.js) 167.16KB 40.99KB
plugin-grid (index.js) 208.18KB 56.62KB
plugin-kanban (index.js) 55.44KB 15.73KB
plugin-list (index.js) 112.73KB 27.69KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.33KB 3.25KB
plugin-view (index.js) 84.54KB 20.84KB
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) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.55KB 2.45KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 13.64KB 4.59KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.27KB 5.47KB
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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

站内信新消息到达完全静默:增加站内 toast 与桌面通知(不含推送通道)

2 participants