Skip to content

fix(app-shell): the console record page reads record:activity's sys_activity table instead of copying it - #5894

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-5878-activity-map-single-reading
Aug 23, 2026
Merged

fix(app-shell): the console record page reads record:activity's sys_activity table instead of copying it#5894
os-zhuang merged 1 commit into
mainfrom
claude/issue-5878-activity-map-single-reading

Conversation

@claude

@claudeclaudeBot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Fixes#5878

What this fixes

RecordDetailView's sys_activity merge hand-copied the table that
record:activity exports as ACTIVITY_TYPE_TO_FEED_TYPE. Neither file
imported the other and nothing compared them, so the two could drift with
nothing going red -- and #5840 drifted them: it added scheduled -> event to
the exported table (a shipped producer, HotCRM's schedule_meeting, writes
that value) and left the copy here untouched. The same sys_activity row
rendered on a hand-authored record page and was dropped on the console record
page.

The copy is deleted. RecordDetailView reads the exported table.

The measured dependency direction, and the shape it decided

Measured before choosing a shape, on origin/main:

  • @object-ui/app-shell declares @object-ui/plugin-detail in
    peerDependencies and devDependencies (not in dependencies).
  • More decisively, RecordDetailView.tsx itself already imports from
    @object-ui/plugin-detail
    -- line 13 on origin/main pulls in
    RecordChatterPanel, InlineEditSaveBar, buildDefaultPageSchema,
    deriveFieldGroupDetailSections, extractMentions, resolveTitleField and
    useRecordEditable. Twelve such specifiers appear across app-shell/src.
  • node scripts/check-phantom-dependencies.mjs agrees the edge is declared:
    Every in-scope import is declared by the package that publishes it.

So the edge already exists, in this very file. Shape (a) applies:
RecordDetailView imports the shared table and deletes its literal. No new
dependency edge is created, no re-homing, no equality-pin stopgap.

One enabling change was needed: the table was exported from
renderers/recordActivityFeed.ts but not from the package entry point, so
it was not importable from outside the plugin. packages/plugin-detail/src/index.tsx
now re-exports it. That adds no module to the eager closure -- on origin/main
index.tsx:24 already statically imports ./renderers/record-activity, which
statically imports ./recordActivityFeed at its line 72, with zero dynamic
imports anywhere on that chain.

recordActivityFeed.ts's membership is untouched.

Why the pins are IDENTITY, not membership

Every value assertion here is also satisfied by a private literal holding
today's members -- i.e. by exactly the re-fork being removed, which would pass
on the defect. Two pins decide the convergence, and neither can be
satisfied by a copy:

  1. a getter spy on the shared object -- the merge's answer for scheduled is
    recorded only if it read that object's property;
  2. a member injected into the shared object at runtime -- a private copy cannot
    contain it, so the seeded row is dropped and the leg goes red.

(2) is deliberately mechanism-independent (it survives any change in how Vitest
implements accessor spies) and states the card's thesis executably: one edit to
the shared table must reach both surfaces.

The identity spy is real because the specifier is identical on both sides.
vitest.config.mts aliases @object-ui/plugin-detail to
packages/plugin-detail/src, and both the test and RecordDetailView import
through that specifier, so ESM hands them one module object -- no dist stands
between an edit and the run.

Reverse verification

Every leg: mutation applied, confirmed on disk by grepping the injected and
the removed text
(never an editor's exit code), run, restored by the script's
trap ... EXIT INT TERM, git diff HEAD --stat empty. Direction predicted
before each run; both matched.

Green baseline on this branch -- Tests 7 passed (7).

Leg A -- a member-identical private copy, scheduled included. The pins'
whole reason for existing. Disk check:
injected_read_site=1 injected_literal=1 removed_read_site=0 private_scheduled_entry=1.

Tests 2 failed | 5 passed (7)
x IDENTITY (spy) - reading `scheduled` goes through the exported table
-> the merge never read ACTIVITY_TYPE_TO_FEED_TYPE.scheduled - it is reading a copy: expected 0 to be greater than 0
x IDENTITY (injected member) - one edit to the shared table reaches this surface
-> Unable to find an element with the text: activity row of type os_probe_5878_not_a_real_activity_type

The scheduledbehaviour leg passed on this defect, as predicted. That is
the demonstration: a value-only assertion would have gone green on the very
fork this change removes.

Leg B -- the historical pre-#5840 copy, scheduled absent. Reproduces the
shipped bug. Disk check: private_scheduled_entry=0.

Tests 3 failed | 4 passed (7)
x SUBJECT - a `scheduled` activity reaches this surface, like the block
x IDENTITY (spy) ...
x IDENTITY (injected member) ...

Leg C -- cross-package, that tsc reads the REBUILT declaration.
packages/app-shell/tsconfig.test.json sets "paths": {}, so
@object-ui/plugin-detail resolves through the built dist/index.d.ts, not
through source. A probe key the real type rejects was appended to the test
file:

error TS1360: Type 'Readonly<Record<string, "email" | "note" | ... | "event" | ...
| undefined>>' does not satisfy the expected type 'Record<string, number>'.

TS1360 naming the real exported union -- not TS2305 "has no exported member",
which is what a stale declaration would have produced. That is what makes the
probe discriminating.

Legs that do NOT discriminate, and why they are kept. The three regression
controls stayed green through both ablation legs by construction -- a
member-identical copy answers them exactly as the shared table does. They are
controls against a convergence that moved membership, not pins. Likewise
ACTIVITY_TYPE_TO_FEED_TYPE.scheduled === 'event' is a statement of where the
reading lives, green in every leg. And the "the row rendered at all" assertion
inside each identity leg is a LIVE control on the same read as the subject, so
a harness that stopped delivering rows fails as a broken probe rather than
reporting the consumer forked.

Verification

All commands from the repo root; exit codes captured before any pipe.

Union re-run after the final commit, on 6ee1addf6:

pnpm exec vitest run --maxWorkers=2 \
packages/app-shell/src/views/RecordDetailView.activityMapIdentity-5878.test.tsx \
packages/app-shell/src/views/RecordDetailView.feedRecordScope.test.tsx \
packages/app-shell/src/views/RecordDetailView.feedLoading.test.tsx \
packages/plugin-detail/src/renderers/__tests__/recordActivityFeed.test.ts \
packages/plugin-detail/src/renderers/__tests__/record-activity.test.tsx
=> Test Files 5 passed (5)
Tests 75 passed (75) UNION_VITEST_EXIT=0

The full packages/app-shell suite was deliberately not run (~784 s, and it
holds the shared verify lock).

Type-check, after pnpm --filter '@object-ui/app-shell^...' build (29 packages)
-- the script name is echoed in each log, so a zero-match --filter cannot read
as green:

> @object-ui/plugin-detail@... type-check tsc --noEmit && tsc -p tsconfig.test.json
PLUGIN_DETAIL_TYPECHECK_EXIT=0
> @object-ui/app-shell@... type-check tsc --noEmit && tsc -p tsconfig.test.json
APP_SHELL_TYPECHECK_EXIT=0

Gate verdict lines, each quoted from the gate's own output:

gateverdict
check-changeset-presence.mjs2 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-no-major.mjsNo changeset declares a 'major' bump.
check-control-bytes.mjscheck-control-bytes: OK (scanned 4902 tracked text file(s); skipped 85 binary).
check-phantom-dependencies.mjsEvery in-scope import is declared by the package that publishes it.
check-package-self-import.mjsNo package names itself inside its own src/.
check-spec-symbol-derivation.mjsspec symbol derivation: 1300 files scanned against 4959 spec export names
check-i18n-call-site-keys.mjsEvery in-scope call-site key resolves against the en pack (2929 keys)
check-node-esm-load.mjs --specifiers-onlyno un-ledgered package emits an extensionless relative specifier

Declared narrowings

Lint. Ran ESLint over the 3 touched files instead of the repo, and the
narrowing is a measurement, not a skip:

  1. Population, from ESLint's own config (its isPathIgnored, not a guess
    about which files count): 3524 tracked .ts/.tsx files are in scope, 0
    ignored.
  2. Files linted, from --format json: 3. eslint EXIT=0,
    errorCount 0, 147 warnings.
  3. Invariance for untouched files: type-aware linting is not enabled --
    the root flat config uses tseslint.configs.recommended and its
    languageOptions declares no parserOptions.project / projectService. So
    every rule's verdict is a function of that file's own text plus the config;
    this diff changes neither the config nor any other file, and cannot move an
    untouched file's verdict.

The 147 warnings were measured against the base rather than assumed. Linting
origin/main's copies of both changed files gives index.tsx 28 warnings
before and 28 after
, and RecordDetailView.tsx 113 before and 113 after --
the re-export added none. The only new warnings are 6 @typescript-eslint/no-explicit-any
in the new test file, matching the fake-dataSource convention of the sibling
harness it is modelled on.

Eager-closure budget -- NOT RUN, and not a pass.node scripts/check-eager-closure-budget.mjs exits 2, its documented
"broken gauge, not a passing budget" code, because it reads
apps/console/dist/eager-closure.json, which only a console vite build
writes. Producing it locally needs app-shell + plugin-gantt /
plugin-markdown / plugin-timeline dists that are not in app-shell's
dependency closure; the attempt failed on TS2307 Cannot find module for
exactly those, unrelated to this diff. performance-budget.yml builds the
console and runs the gauge on this PR. The specific claim it would check is
supported statically above: the re-exported module was already eagerly reachable
from the barrel on origin/main.

Out of scope, deliberately

Only the table converges. The row-to-FeedItem construction around it is
still written twice -- this merge builds the item inline where the block calls
activityRowToFeedItem -- so the timestamp fallback and the actor fallback are
still duplicated, and an unmapped activity type is still dropped silently
here where the block warns once. That is the same class of mirror one level up
and is filed separately; it is not addressed here.

#5877 (the unreachable FeedItemType kinds) and objectstack#11424 (whether the
platform vocabulary should gain scheduled) remain open and are out of scope
for this card.


Generated by Claude Code

…ctivity table instead of copying it
`RecordDetailView`'s `sys_activity` merge carried a hand-written copy of the
table `record:activity` exports as `ACTIVITY_TYPE_TO_FEED_TYPE`. Neither file
imported the other and nothing compared them, so the two could drift with
nothing going red -- and objectui#5840 drifted them: it added `scheduled` ->
`event` to the exported table (HotCRM's `schedule_meeting` writes that value)
and left the copy here untouched. The same row rendered on a hand-authored
record page and was dropped on the console record page.
- `plugin-detail` re-exports `ACTIVITY_TYPE_TO_FEED_TYPE` from its entry point;
the module holding it was already in the eager closure via the renderer
beside it, so nothing new is pulled in.
- `RecordDetailView` imports it and the local literal is deleted.
- The pins are IDENTITY, not membership: a getter spy on the shared object and
a member injected into it at runtime. A member-identical private copy -- the
failure being removed -- fails both, where a value comparison would pass on
the defect.
Fixes#5878
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuPCi56cnGyykygi3z9w4m
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3229.9 KB3990.2 KB
Main entry chunk (gzip)153.6 KB350 KB
Entry fileindex-Bu2TZ2j6.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)10.13KB3.77KB
app-shell (runtime-config.js)13.57KB4.78KB
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)504.16KB114.08KB
core (index.js)4.92KB1.97KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)164.55KB45.67KB
fields (index.js)238.40KB59.89KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)187.32KB44.31KB
plugin-dashboard (index.js)133.32KB34.42KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)242.97KB61.32KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.63KB30.64KB
plugin-gantt (index.js)164.15KB39.88KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.80KB27.20KB
plugin-map (index.js)20.06KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.49KB7.59KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.57KB20.74KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)52.40KB17.45KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.33KB0.69KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (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)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.88KB1.85KB
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-zhuang
os-zhuang marked this pull request as ready for review August 23, 2026 18:43
@os-zhuang
os-zhuang added this pull request to the merge queueAug 23, 2026
Merged via the queue into main with commit d06fba8Aug 23, 2026
23 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5878-activity-map-single-reading branch August 23, 2026 18:43
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] RecordDetailView hand-copies record:activity's sys_activity map, and nothing fails when the two disagree

2 participants

@os-zhuang@claude