Skip to content

fix(plugin-calendar): calendar-view consumes or declares every forwarded prop — authored onEventClick can no longer crash a click (#4453) - #4494

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-4453-calendar-props-contract
Aug 12, 2026
Merged

fix(plugin-calendar): calendar-view consumes or declares every forwarded prop — authored onEventClick can no longer crash a click (#4453)#4494
yinlianghui merged 2 commits into
mainfrom
claude/issue-4453-calendar-props-contract

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Closes #4453.

The defect

calendar-view's renderer passed onEventClick={handleEventClick} and then spread the remaining props after it. Anything arriving under that key won, and both authoring channels reach it — the node's own key and a props: { … } container:

authored renders? click
onEventClick: 'NOT-A-FUNCTION' (node key) yes, normally window error: onEventClick is not a function
props: { onEventClick: 'NOT-A-FUNCTION' } yes, normally same

Worse than an error boundary, which is why it was not cosmetic: React does not route event-handler errors to SchemaErrorBoundary, so this surfaced as an uncaught window error. The calendar kept looking fine while its click handling was dead.

The contract

The #4425 phase-2 ruling (option 1 — promote the whitelist to the SDUI widget contract, comment 5270759246) applied to this widget, per the ruling on this card (comment 5272248819). The raw {...props} spread into CalendarView is gone. The forward set is now exactly CalendarViewProps, each key resolved to the type that prop declares; everything else is dropped. The rest object is READ for declared keys and never spread — that is the whole of the fix.

onEventClick is a declared, function-typed host escape hatch, not a strip and not a lenient coercion: one key, two producers (an SDUI author, whose value can never be a function; a React host, whose passthrough works today and must keep working), and only the value's TYPE can separate them. The discrimination lives in a declared contract at the renderer boundary — the #4435 declared-passthrough pattern — so off-type input gets the one answer every other resolver in this file gives: dropped, the same answer as an absent key.

The whole callback family is listed rather than onEventClick alone, because the defect is the family's: an authored onDateClick / onNavigate / onViewChange / onEventDrop / onTimeRangeSelect / onAddClick string killed its own gesture identically, and each is a prop CalendarView already declares — so this narrows what may reach the component and widens nothing. Four of them (onEventClick, onDateClick, onViewChange, onNavigate) are additionally published as calendar-view's API in content/docs/plugins/plugin-calendar.mdx; dropping those would have removed a documented, working host path, which #4433's ruling refused to do silently.

This completes the calendar renderer's migration: #4433 (events strip, PR #4455) and #4452 (currentDate parse, PR #4484) were the first two keys; this card does the rest of the set.

Census — every key the old spread could carry

Injected or forwarded by SchemaRenderer (packages/react/src/SchemaRenderer.tsx, the createElement block):

key disposition
schema consumed — the event source (schema.data and the field-name inputs)
className (merged node className + scope class) consumed → forwarded; SchemaRenderer sets it AFTER the node's props container, which is why an authored props.className was never an exposure here (verified, pinned)
events dropped (#4433) — SDUI action metadata, legal on any node, no read site in the renderer layer
props container (forwarded as a prop of its own), properties, bind dropped
ariaLabel, ariaDescribedBy, role and the resolved aria-label / aria-describedby / role droppedCalendarView names its own region (role="region", aria-label="Calendar"); unchanged from today, where the component ignored them
disabled (always passed as __disabled or undefined) dropped
data-obj-id, data-obj-type, data-obj-schema-invalid, data-debug-type, data-debug-id dropped
id, name, label, description, placeholder, … (BaseSchema keys) dropped
the OPEN TAIL — any authored key at all dropped; this is the half a deny-list structurally cannot close

Declared registry inputs (all 11 audited):

input disposition
data consumed via schema.data
titleField, startDateField, endDateField, allDayField, colorField consumed via schema.* in the events memo
view consumed, narrowed to its declared enum, forwarded
currentDate consumed, parsed to a Date (#4452), forwarded
className consumed → forwarded (above)
colorMapping no read site anywhere — declared and inert. Dropped here (it never did anything), filed as #4493
allowCreate no read site — the handleAddClick it would drive is built and never passed. Dropped here; that is #4454's card and is deliberately NOT fixed in this PR

Declared host escape hatches (typed; nothing here is authorable surface, so the registry inputs list is untouched):

key forwarded when
onEventClick, onDateClick, onViewChange, onNavigate, onAddClick, onEventDrop, onTimeRangeSelect the value is a function
locale the value is a string Intl.getCanonicalLocales accepts
slotMinutes the value is a finite number greater than 0
onAction (the renderer's own action channel, consumed not forwarded) the value is a function

Three extras that fell out of the census, all fixed here because they are the same defect at the same boundary:

  • onAction reached this renderer through the identical props channel and died on the identical click: an authored onAction: 'NOT-A-FUNCTION' threw onAction is not a function. Typing the forwarded handler and not the consumed one would have been half a fix.
  • locale is a render-time crash channel: MEASURED, toLocaleDateString('en_US') throws RangeError: Incorrect locale information provided — the underscore spelling a producer writes by accident — and so do '', '123', 'a'. It is kept (a host may legitimately pass one) but validated, rather than dropped or forwarded raw.
  • view: CalendarView renders its body under month / week / day only, so an off-enum value such as agenda produced a header with no calendar under it at all. Off-enum now gets the absent-key answer, i.e. the component's month default.

Red-first (verbatim)

packages/plugin-calendar/src/calendar-view-renderer.propsContract.test.tsx, run against the pre-fix renderer (git checkout origin/main -- calendar-view-renderer.tsx, restored after): 6 failed | 9 passed (15) — the 6 new pins red, the 9 must-not-change pins green on BOTH sides.

Handler errors do not reach SchemaErrorBoundary, so the pins capture the window error event, a synchronous throw out of fireEvent, and console.error, and assert on the union — the environment does not get to decide the verdict by moving the report between channels.

FAIL … > drops an authored `onEventClick` string written on the NODE — the click is a no-op
AssertionError: expected [ …(2) ] to deeply equal []
+ [
+   "window.error: onEventClick is not a function",
+   "console.error: onEventClick is not a function",
+ ]

FAIL … > drops an authored `onEventClick` string written in the `props` CONTAINER
+ [
+   "window.error: onEventClick is not a function",
+   "console.error: onEventClick is not a function",
+ ]

FAIL … > drops an authored `onAction` string — the SAME click, the same crash, the same answer
+ [ "window.error: onAction is not a function", "console.error: onAction is not a function" ]

FAIL … > drops an authored `onDateClick` string — the whole handler family, not just the reported key
+ [ "window.error: onDateClick is not a function", "console.error: onDateClick is not a function" ]

FAIL … > treats an off-enum `view` as ABSENT — a usable month calendar, not a bodyless one
AssertionError: expected null not to be null    (no month grid rendered at all)

FAIL … > renders through an authored `locale` that `Intl` rejects, instead of throwing out of render
   Component "plugin-calendar:calendar-view" failed to render
   Incorrect locale information provided

Post-fix: all 15 green.

Must-not-change (green on both sides)

Verification

pnpm --filter '@object-ui/plugin-calendar^...' build            # dependency closure first
tsc --noEmit && tsc -p tsconfig.test.json                       # plugin-calendar, both: 0 errors
vitest run packages/plugin-calendar packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx
    -> Test Files 8 passed (8) | Tests 85 passed (85)
vitest run packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx --reporter=verbose
    -> 39 passed; plugin-calendar:calendar-view / :object-calendar / view:calendar all green
eslint packages/plugin-calendar        -> 0 errors (109 pre-existing warnings, none in the changed files)
node scripts/check-control-bytes.mjs   -> OK (4184 tracked text files)

The DOM-leak sweep is #4432's surface and was not edited — run read-only to confirm the calendar targets stay green.

Emitted .d.ts measured both ways (build on this branch vs. with the renderer reverted to origin/main): packages/plugin-calendar/dist/index.d.ts is byte-identical, 10 lines, no diff. The published surface does not grow, so this is a patch and not a minor.

Out of scope, filed

Follow-up for #4425's completion pass (reported, not implemented)

currentDate is now safe to plant as a plugin-calendar:calendar-view canary in widget-dom-leak-sweep.test.tsx: post-#4484 the authored ISO string is parsed at the boundary instead of crashing the component, and post-this-PR it cannot reach any other prop. The same is true of the handler keys — an authored onEventClick canary now renders and clicks cleanly. That file is #4432's surface, so the extension belongs to the completion pass, not here.


Generated by Claude Code

@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 12, 2026 8:58pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 24.7 KB 350 KB
Entry file index-BpkM3cFf.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 9.56KB 3.59KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 8.92KB 3.41KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
auth (index.js) 2.35KB 1.07KB
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) 4.91KB 0.87KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 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.65KB 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) 489.32KB 108.45KB
core (index.js) 3.37KB 1.34KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 153.42KB 41.19KB
fields (index.js) 230.07KB 57.07KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 3.35KB 1.38KB
i18n (pickLocalized.js) 3.69KB 1.73KB
i18n (provider.js) 23.12KB 7.62KB
i18n (useDisplayLocale.js) 2.33KB 1.20KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 38.98KB 10.85KB
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.74KB
mobile (index.js) 1.50KB 0.62KB
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.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.13KB 12.76KB
plugin-charts (index.js) 62.01KB 17.63KB
plugin-chatbot (index.js) 181.21KB 43.14KB
plugin-dashboard (index.js) 120.75KB 31.38KB
plugin-designer (index.js) 212.58KB 42.83KB
plugin-detail (index.js) 239.03KB 59.77KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.58KB 27.68KB
plugin-gantt (index.js) 164.14KB 39.98KB
plugin-grid (index.js) 188.13KB 50.00KB
plugin-kanban (index.js) 48.62KB 13.42KB
plugin-list (index.js) 110.47KB 26.90KB
plugin-map (index.js) 18.16KB 5.81KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 41.16KB 10.96KB
plugin-timeline (index.js) 26.21KB 7.52KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.08KB 20.55KB
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.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 23.73KB 7.96KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.23KB 0.66KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.05KB 1.52KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
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 (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
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

Copy link
Copy Markdown
Collaborator Author

ACCEPT — step-7 复核 by PM session session_017Qqyix2QcnpUC9XeYVDzx3 (focused review).

Flipping ready + arming auto-merge. #4492 dispatches next with this PR's pattern; #4493/#4454 pool as an enforce-or-remove pair for triage.


Generated by Claude Code


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 12, 2026 21:15
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 12, 2026
Merged via the queue into main with commit c5756ff Aug 12, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4453-calendar-props-contract branch August 12, 2026 21:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants