Skip to content

fix(plugin-timeline): declare who owns the bare timeline key, and pin it against a reorder - #6417

Merged
os-support-ai merged 1 commit into
mainfrom
claude/issue-6353-timeline-double-registration
Aug 25, 2026
Merged

fix(plugin-timeline): declare who owns the bare timeline key, and pin it against a reorder#6417
os-support-ai merged 1 commit into
mainfrom
claude/issue-6353-timeline-double-registration

Conversation

@claude

@claudeclaudeBot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Fixes#6353

packages/plugin-timeline registers the same short name twice, and until now both registrations also claimed the baretimeline key, so the winner was whichever module evaluated last.

sitecomponentnamespaceclaimed bare?
src/renderer.tsx:498TimelineRenderer (presentational)plugin-timelineyes → now no
src/index.tsx:366ObjectTimelineRenderer (object-bound)viewyes

src/index.tsx re-exports ./renderer (line 300) before its own import (line 307), so the presentational registration ran first and the object-bound one overwrote it. The outcome was the intended one; the mechanism was module-evaluation order. Reordering those two lines would have handed type: 'timeline' to the presentational renderer, which reads none of the object-bound keys (object / filter / sort / limit) — an authored timeline would have stopped fetching, with no error and no failing test.

The change

The fixsrc/renderer.tsx registers with skipFallback: true, the remedy the registry's own collision guard names in its warning text (packages/core/src/registry/Registry.ts:246-266). No second mechanism is introduced. Only view:timeline claims the bare key now, in any evaluation order; the presentational renderer stays reachable under its explicit plugin-timeline:timeline key. Resolution is unchanged todaytype: 'timeline' still renders ObjectTimelineRenderer — it is now decided rather than inherited.

The pinsrc/__tests__/timeline-bare-key-ownership.test.ts, 5 assertions. It reddens if the declaration is dropped, if a third registration starts claiming the bare key, or if resolution becomes order-dependent again.

The load-bearing one is the replay: asserting only today's outcome cannot distinguish declared from happened to be last. So the test reads both registrations' real declared metadata back out of the registry — nothing in it is a hand-copied mirror of the source, so it cannot drift from it — and replays them into a fresh Registry in both orders:

const{type: _fullType, component, ...meta}=ComponentRegistry.getConfig(BARE,namespace)!;fresh.register(BARE,component,meta);

Order-independence is then a property under test rather than a property of the file the test happens to import. The pin also asserts the collision guard stays silent, since that warning is the mechanism this fix uses.

Ghost-assertion check — the pin was proven able to fail

Mutation: delete skipFallback: true from src/renderer.tsx (the pre-fix condition). Predicted before the run: red. Mutation confirmed on disk before measuring — grep -c 'skipFallback: true' went 1 → 0 and the blob hash moved off the HEAD blob; restore was by git checkout HEAD -- <abs path> and verified by hash equality plus an empty git diff HEAD, not by exit code.

 Test Files 1 failed (1)
Tests 4 failed | 1 passed (5)
× both registrations exist, and exactly one of them claims the bare key
AssertionError: expected [ 'plugin-timeline:timeline', …(1) ] to deeply equal [ 'view:timeline' ]
× the presentational registration declares skipFallback; the object-bound one does not
AssertionError: expected undefined to be true
× resolves the same way in EITHER evaluation order (object-bound first — the reorder that used to swap the renderer)
AssertionError: registering in the order [view, plugin-timeline] changed who answers bare "timeline":
expected [Function TimelineRenderer] to be [Function ObjectTimelineRenderer]

The prediction was one failure short, and the miss is worth recording. I predicted 3 of 5 red and got 4: the presentational first — what ../index evaluates today row also failed, on the collision-warning assertion (the registry warned that the bare-name fallback was overwritten) rather than on resolution. In that order the pre-fix code resolves bare timelinecorrectly — and still warns, which is precisely the registry telling us the answer was an accident.

The one row that stayed green under the mutation is the live-singleton assertion (resolves bare to the object-bound renderer), because pre-fix the real registry still resolved correctly by evaluation order. That is the concrete demonstration that a live-outcome assertion alone would have been a ghost assertion here: it passes in both worlds. The replay is what carries the pin.

Census — report only, not fixed here (#6353's closing question)

Censused the registration call shape, not the key string: every .register( / .registerLazy( call across packages/** and apps/** (1575 files, 267 literal-key registrations), extracting (key, namespace, skipFallback) and reporting bare keys claimed by more than one namespace. Each hit was then read.

False positives, all confirmed by reading: '${type}' and 'button' are Registry.ts's own JSDoc examples and its deprecation-warning template; 'form' / 'list' at packages/react/src/spec-bridge/SpecBridge.ts:20-21 are a different registry (SpecBridge's own Map<string, BridgeFn>), not ComponentRegistry. The dynamic-key sites are all deliberate and already guarded — packages/fields drives skipFallback off a declared FIELD_TYPES_SKIP_FALLBACK set and registers tombstones with skipFallback: true; placeholders.tsx:126 guards with if (!ComponentRegistry.get(type)).

One real double-claim outside plugin-timeline, filed as #6416 and deliberately not touched here: packages/plugin-report registers report / spec-report / report-viewer under namespace report, while apps/console/src/register-plugins.ts:135 and its variant loop declare the lazy stubs under plugin-report. Two consequences — bare report is double-claimed order-dependently (this card's shape), and the plugin-report:* keys are never satisfied by a real registration, yet packages/cli/src/utils/known-schema-types.ts:345-347 whitelists all three. Which spelling is canonical is a judgement call with consumer-visible consequences either way, so it is filed for triage rather than decided in this PR.

Verification

All of the below ran on the exact tree of the final commit, git rev-parse --short HEAD = 48da81623 (git diff HEAD empty at the time of each run). Heavy runs went through the container's shared verify lock.

checkresult
pnpm exec vitest run packages/plugin-timeline/src/ (canonical root invocation)Test Files 10 passed (10) · Tests 73 passed (73)
pnpm --filter @object-ui/plugin-timeline type-checkexit 0 — echoed tsc --noEmit && tsc -p tsconfig.test.json, so not a zero-match script
pnpm --filter '@object-ui/plugin-timeline^...' build (dependency closure, built first)exit 0
node scripts/check-changeset-presence.mjsexit 0
node scripts/check-changeset-no-major.mjs / check-changeset-fixed.mjsexit 0
node scripts/check-control-bytes.mjsexit 0
node scripts/check-doc-component-types.mjsexit 0

tsconfig.test.json includes src/**/*.test.ts, so the new test file is genuinely in the type-check program rather than excluded from it.

Lint was narrowed, and here is why the narrowing is a measurement rather than a skip. Repo-wide pnpm lint is CI's run. Locally I linted the diff: pnpm exec eslint <the two changed source files> --format json reports 2 files, 0 errors, 15 warnings. All 15 warnings are react-refresh/only-export-components and @typescript-eslint/no-explicit-any at lines ≤ 463, i.e. entirely above my edit region (498+) — pre-existing, none introduced here. The population is eslint's own: its config lints **/*.{ts,tsx}, which covers exactly those two of my three changed files (the third is .changeset/6353-*.md). And the narrowing cannot hide anything, because type-aware linting is not enabledeslint.config.js extends tseslint.configs.recommended and contains zero occurrences of projectService, parserOptions.project or recommendedTypeChecked — so no rule reads across file boundaries and this diff cannot move the verdict on any file it did not touch.

A changeset is included (patch, @object-ui/plugin-timeline); no major, per the fixed-group rule.

Generated by Claude Code


Generated by Claude Code

`plugin-timeline:timeline` (presentational, `src/renderer.tsx`) and
`view:timeline` (object-bound, `src/index.tsx`) both claimed the bare
`timeline` fallback, so the winner was whichever module evaluated last.
`src/index.tsx` re-exports `./renderer` before its own `import`, so the
object-bound renderer won by accident.
Register the presentational renderer with `skipFallback: true` — the remedy
the registry's collision guard names — so only `view:timeline` claims the bare
key, in any evaluation order. Resolution is unchanged today; it is now decided
rather than inherited.
Add `src/__tests__/timeline-bare-key-ownership.test.ts`, which replays both
registrations' real declared metadata into a fresh `Registry` in both orders,
so a reorder reddens instead of silently swapping renderers.
Ref objectui#6353
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3230.7 KB3266.6 KB
Main entry chunk (gzip)156.1 KB350 KB
Entry fileindex-DUXjsx_Y.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)11.30KB4.28KB
app-shell (runtime-config.js)18.10KB6.51KB
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)505.86KB114.58KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.18KB47.97KB
fields (index.js)238.89KB60.02KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.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.66KB12.84KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.60KB44.82KB
plugin-dashboard (index.js)133.46KB34.48KB
plugin-designer (index.js)211.90KB42.74KB
plugin-detail (index.js)245.10KB62.31KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)131.78KB32.19KB
plugin-gantt (index.js)164.14KB39.87KB
plugin-grid (index.js)201.79KB54.60KB
plugin-kanban (index.js)52.87KB14.57KB
plugin-list (index.js)112.63KB27.45KB
plugin-map (index.js)20.09KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.72KB7.71KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)84.55KB20.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)4.47KB1.63KB
react (SchemaRenderer.js)54.84KB18.43KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.35KB0.70KB
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)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-support-ai
os-support-ai marked this pull request as ready for review August 25, 2026 23:42
@os-support-ai
os-support-ai added this pull request to the merge queueAug 25, 2026
Merged via the queue into main with commit d813e8bAug 25, 2026
29 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-6353-timeline-double-registration branch August 25, 2026 23:54
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.

'timeline' is registered twice, and the bare-name fallback resolves to the later registration by module-evaluation order

2 participants

@os-support-ai@claude