Skip to content

fix(plugin-detail): record:activity never widens on an unusable types filter (#5841) - #5890

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-5841-activity-types-filter
Aug 23, 2026
Merged

fix(plugin-detail): record:activity never widens on an unusable types filter (#5841)#5890
os-zhuang merged 1 commit into
mainfrom
claude/issue-5841-activity-types-filter

Conversation

@claude

@claudeclaudeBot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Fixes#5841

record:activity sanitised its own types prop and turned an unrecognised or empty kind list into no filter, so an author who named the wrong kind was served every activity on the record with no diagnostic anywhere at runtime.

Re-measured at the live source

The card measured a shipped bundle (@objectstack/console 17.1.0, dist/assets/plugins-views-BaBStVok.js). Re-measured here at the repo source, packages/plugin-detail/src/renderers/recordActivityFeed.ts at eddc1dd97 — the premise holds exactly, minifier names aside:

exportfunctionnormalizeFeedTypes(value: unknown): FeedItemType[]|undefined{if(!Array.isArray(value))returnundefined;constkept=value.filter((v): v is FeedItemType=>typeofv==='string'&&FEED_ITEM_TYPE_VALUES.includes(v),);returnkept.length>0 ? kept : undefined;// EMPTY REMAINDER -> undefined}

and the call site in applyFeedConfig:

consttypes=normalizeFeedTypes(config.types);if(types){// undefined => no filter at allconstallowed=newSet<string>(types);kept=kept.filter((i)=>allowed.has(i.type));}

The source even documented the collapse as intended — "an all-unknown list is treated as 'not configured' rather than 'show nothing', so a typo cannot empty the feed silently" — and a test pinned it under the title "a types list of nothing but typos does not silently empty the feed". Both are replaced here.

The principle

A sanitiser may narrow an author's request or refuse it, but it must never silently widen it. Widening turns a typo into "show the user everything", which is the one outcome no author asked for — and it hides behind a plausible result. A populated timeline reads as working; an empty one gets investigated. That is why this shipped: a lead page authored types: ['crm_task'] (an object name where a feed kind belongs) and its Activity tab rendered the audit stream for as long as it shipped.

undefined is now reserved for one meaning — no types key was authored. An authored filter that keeps nothing returns [], and the call site tests !== undefined rather than truthiness.

Ruled behaviour, with controls

authoredresultevidence
types omittedno filter — every kind (unchanged)CONTROL: ['c1','f1','s1']
types: ['comment','system']filters to those kinds (unchanged)CONTROL: ['c1','s1'], no diagnostic
types: []filters to nothing[]
types: ['crm_task'] (all unrecognised)filters to nothing + one diagnostic naming the kinds[], 1 warn
types: ['comment','crm_task'] (mixed)keeps the recognised members; unrecognised named in the same diagnostic['c1'], 1 warn

The recognised vocabulary is derived from the spec's own FeedItemType at runtime and in the tests (SpecFeedItemType.options — 13 values), never hand-typed. A guard asserts that the value the suite calls unrecognised really is outside that vocabulary, so the suite cannot quietly start testing nothing if crm_task ever became a declared kind.

The diagnostic

Follows the convention #5886 landed in this same file: deduped so one bad kind warns once however many times the feed re-renders, with a test seam to reset (resetUnrecognisedFeedTypeWarnings). No NODE_ENV guard — the package carries 8 console.warn sites and zero such guards, and the failure being fixed is invisibility.

Rather than a second mechanism, #5886's warn plumbing is factored into a shared warnOnce(bucket, keys, build) used by both sites, with a bucket per channel. The two vocabularies overlap — crm_task is a plausible unmapped sys_activity.typeand a plausible unrecognised types entry — so a shared dedupe bucket would let whichever fired first swallow the other. A test pins that they stay apart.

types: [] warns nothing: the request was carried out exactly, and warning about a request that was honoured teaches authors to ignore the channel (the same posture the unknown-activity-type warning already takes).

Bounded extension beyond the four ruled rows — declared

A types that is not an array at all (types: 'comment' — brackets dropped) is refused rather than ignored, returning [] with its own diagnostic. This is the same defect class in the same function: the kind is spelled correctly, so vocabulary alone cannot catch it, and ignoring it rendered the whole audit stream — the exact failure this card is about. Leaving it would have left one silently-widening branch inside the function whose contract now says it never widens. Flagged for the PM to rule back if the fence is meant to be literal; it is a one-line change to revert.

Reverse verification

Three ablations. Each one's mutation was confirmed on disk by grepping the injected and removed text (never an editor exit code), each ran under a trap … EXIT INT TERM restore, and each restored to an empty git diff HEAD --stat. No rebuild is involved and none is needed: the tests import ../recordActivityFeed — a relative in-package specifier that resolves to src/, so dist/ is not on the path. The A1 leg going red with no rebuild is itself the proof of that.

legmutationpredictedobserved
A1 restore the wideningreturn kept;return kept.length > 0 ? kept : undefined;the empty / all-unrecognised rows red, controls and mixed green4 failed, 57 passedtypes: [], all-unrecognised, the replaced typo pin, and the normalizeFeedTypes distinction
A2 call-site truthinessif (types !== undefined)if (types)green — non-discriminating61 passed
A3 remove the diagnosticwarnOnce(…, unrecognised, …)warnOnce(…, [], …)the three diagnostic legs red, filtering green3 failed, 58 passed — names-once, names-every, channels-apart

A2 is reported as a null result rather than a guard.[] is truthy in JS, so if (types) and if (types !== undefined) behave identically given the new sanitiser: the call-site change is intent-clarifying, and every behavioural guard lives in normalizeFeedTypes. Non-discriminating legs elsewhere: both controls and the mixed row stay green under A1 (the mixed row returns a non-empty array, so the ablated branch never runs), and every filtering leg stays green under A3.

Verification

All at 582348918, exit codes captured before any pipe, each verdict line quoted from the gate itself. Heavy legs ran through the shared verify lock.

  • npx vitest run …/recordActivityFeed.test.ts …/record-activity.test.tsx --maxWorkers=2Test Files 2 passed (2) · Tests 61 passed (61)
  • pnpm --filter '@object-ui/plugin-detail^...' build → lock verdict command-exit 0 (run first — a fresh worktree has no dist/)
  • pnpm --filter @object-ui/plugin-detail type-checkcommand-exit 0, script echoed as > tsc --noEmit && tsc -p tsconfig.test.json (not a zero-match no-op)
  • node scripts/check-changeset-presence.mjsEXIT=0 · "✅ 2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)"
  • node scripts/check-changeset-no-major.mjsEXIT=0 · "✅ No changeset declares a major bump."
  • node scripts/check-changeset-fixed.mjsEXIT=0 · "✅ All workspace packages are in the changeset fixed group."
  • node scripts/check-control-bytes.mjsEXIT=0 · "✅ check-control-bytes: OK (scanned 4901 tracked text file(s); skipped 85 binary)." Plus a per-file scan of the diff with grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]': all four clean.
  • node scripts/check-doc-component-types.mjsEXIT=0 · "✅ Every documented component type is registered."
  • node scripts/check-doc-links.mjsEXIT=0 · "Links are valid across 13 scan roots."
  • node scripts/check-doc-snippet-types.mjsEXIT=0 · "Semantic phase: 101 of 101 block(s) judged, 0 failed." First run reported [unbuilt-package] and said "The snippet program was NOT run" — a precondition, not a verdict. Rather than call that environmental, the gate's own --build-filter was fed to turbo run build … --concurrency=2 exactly as doc-snippet-types.yml does (32 successful, 32 total, @object-ui/plugin-detail among them) and the gate re-run for a real judgement.

Lint narrowing, declared and measured. Repo-wide pnpm lint is CI's run; the narrowing here is a measurement, not a skip:

  1. Population, from eslint's own configeslint.config.js scopes every block to files: ['**/*.{ts,tsx}']. ESLint itself reports the changeset and the .mdx as "File ignored because no matching configuration was supplied."
  2. Count, from --format json — 4 paths returned, 2 actually judged (the .ts pair), 0 errors / 0 warnings on both.
  3. Invariance for untouched files — no type-aware linting is configured anywhere (projectService / parserOptions.project / project:0 hits in eslint.config.js), so this diff cannot move the verdict of any file it does not touch.

Beyond the narrowing, the affected package's own CI gate ran whole: pnpm --filter @object-ui/plugin-detail lint (eslint .) → command-exit 0. Its warnings are pre-existing and in files this PR does not touch.

Scope

Sanitiser + its call site in recordActivityFeed.ts, its tests, the changeset, and the record:activity docs row that documented the old types behaviour. ACTIVITY_TYPE_TO_FEED_TYPE is untouched — #5886 settled it. Nothing here touches the storage path; PageComponentSchema.properties remains an open bag, which the card itself names as the platform-side half.

Behaviour change, stated in the changeset: a page authoring types: [] or an all-unrecognised list now renders an empty timeline where it previously rendered everything. That is the fix, not a regression.


Generated by Claude Code

…s` filter (#5841)
`normalizeFeedTypes` returned `undefined` for three different authored inputs — no
`types` key, `types: []`, and a list whose every member was unrecognised — and
`applyFeedConfig` reads `undefined` as "apply no filter". So a page that named the wrong
feed kind was served EVERY activity on the record, with no diagnostic anywhere at
runtime. Measured in a real app: a lead page authored `types: ['crm_task']` (an object
name where a feed kind belongs) and its Activity tab rendered the audit stream for as
long as it shipped.
A sanitiser may narrow an author's request or refuse it; it must never silently widen
it. Widening turns a typo into "show the user everything" — the one outcome no author
asked for — and it hides behind a PLAUSIBLE result, since a populated timeline reads as
working while an empty one gets investigated.
`undefined` is now reserved for one meaning: no `types` key was authored. An authored
filter that keeps nothing returns `[]`, and the call site tests `!== undefined` rather
than truthiness, so `[]` filters to nothing. A non-array `types` is refused for the same
reason rather than ignored.
Unrecognised entries are named once, through the same warn-once plumbing #5886 added
for unmapped `sys_activity.type` values, now factored into a shared `warnOnce` with a
bucket per channel — the two vocabularies overlap, so one channel having spoken must not
silence the other.
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)3230.4 KB3990.2 KB
Main entry chunk (gzip)153.6 KB350 KB
Entry fileindex-DxNqfSaE.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)243.97KB61.70KB
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 0d1e702Aug 23, 2026
23 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5841-activity-types-filter 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.

record:activity widens on a bad types filter: an unrecognised or empty kind list renders EVERY activity, not none

1 participant

@os-zhuang