diff --git a/.changeset/calendar-view-props-contract-4453.md b/.changeset/calendar-view-props-contract-4453.md
new file mode 100644
index 000000000..3320f9f91
--- /dev/null
+++ b/.changeset/calendar-view-props-contract-4453.md
@@ -0,0 +1,13 @@
+---
+'@object-ui/plugin-calendar': patch
+---
+
+`calendar-view` consumes or declares every prop it forwards — an authored `onEventClick` can no longer crash a click
+
+`calendar-view`'s renderer ended in ``, where `props` was everything `SchemaRenderer` hands a registered widget: the node's authored keys, the contents of its `props` container, the injected runtime props, and a host's trailing props. That is an unbounded set spread onto a component whose props are a closed list, and the worst collision on it was `onEventClick`: an authored `onEventClick: 'NOT-A-FUNCTION'` rendered a perfectly normal calendar and then threw `onEventClick is not a function` on the first click. React does not route event-handler errors to `SchemaErrorBoundary`, so it surfaced as an uncaught window error — the calendar kept looking fine while its click handling was dead. Both authoring channels reached it, the node's own key and a `props: { onEventClick }` container.
+
+The forward set is now exactly `CalendarViewProps`, each key resolved to the type that prop declares; nothing else reaches the component. Declared registry inputs are consumed (`view` narrowed to its declared enum, `currentDate` parsed, `className` forwarded, the field-name inputs read off the schema); `CalendarView`'s callbacks are a declared, function-typed host escape hatch — a host-passed function is forwarded exactly as before, and a non-function value, which is all an SDUI author writing JSON can produce, is dropped, the same answer as an absent key; every other key is dropped.
+
+Fixed with it, from the same boundary: an authored `onAction` string killed the same click through the renderer's own action channel; an authored `onDateClick` / `onNavigate` / `onViewChange` / `onEventDrop` / `onTimeRangeSelect` / `onAddClick` string killed its own gesture the same way; an authored `locale` that `Intl` rejects (`en_US`, the underscore spelling) took the whole render down to the error boundary with `RangeError: Incorrect locale information provided`; and an off-enum `view` (`agenda`) rendered a header with no calendar under it at all, where it now falls back to the component's `month` default.
+
+No capability is removed and no authorable surface is added: every host path that worked keeps working, including the handler precedence the old spread produced (a host handler replaces the `onAction` dispatch rather than running alongside it). The package's emitted `.d.ts` is unchanged.
diff --git a/packages/plugin-calendar/src/calendar-view-renderer.propsContract.test.tsx b/packages/plugin-calendar/src/calendar-view-renderer.propsContract.test.tsx
new file mode 100644
index 000000000..a09c8ce72
--- /dev/null
+++ b/packages/plugin-calendar/src/calendar-view-renderer.propsContract.test.tsx
@@ -0,0 +1,578 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * `plugin-calendar:calendar-view` — the renderer forwards only what it consumes
+ * or declares (objectui#4453; the objectui#4425 phase-2 whitelist contract
+ * applied to this widget).
+ *
+ * The renderer used to end in ``, where `props` was
+ * everything `SchemaRenderer` hands a registered widget. Two collisions on that
+ * spread were already user-reachable crashes with their own cards and their own
+ * pin files next to this one — objectui#4433 (`events`) and objectui#4452
+ * (`currentDate`). This file pins the third, which is the worst of the three:
+ *
+ * an authored `onEventClick: 'NOT-A-FUNCTION'` renders a perfectly normal
+ * calendar and then throws `onEventClick is not a function` on the first
+ * click.
+ *
+ * ## Why this one is worse than an error boundary
+ *
+ * React does not route event-handler errors to `SchemaErrorBoundary`: a render
+ * error becomes the boundary's tidy alert, but a handler error escapes to the
+ * window as an UNCAUGHT error. So the calendar keeps looking fine while its
+ * click handling is dead — nothing on screen says anything is wrong. The
+ * assertions below therefore cannot use the boundary marker the sibling files
+ * use; they capture the window `error` event, a synchronous throw out of
+ * `fireEvent`, and `console.error` (see {@link clickErrors}), because which of
+ * the three carries the report is the DOM implementation's business and not
+ * this contract's.
+ *
+ * ## The two authoring channels
+ *
+ * Both are pinned for every authored case, per objectui#4452's lesson: the
+ * node's own key, and the `props: { … }` container whose contents
+ * `SchemaRenderer` spreads separately. A fix that only handled one would leave
+ * the other half live.
+ *
+ * ## What must NOT change
+ *
+ * A host-passed FUNCTION is the component's genuine escape hatch — the working
+ * path the card forbids breaking. It survives as a DECLARED, function-typed
+ * hatch, and it keeps its old precedence: a host handler REPLACES the `onAction`
+ * dispatch rather than running alongside it (that is what the trailing-props
+ * spread did, so that is what is pinned).
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import React from 'react';
+import { render, screen, waitFor, fireEvent } from '@testing-library/react';
+import { SchemaRenderer } from '@object-ui/react';
+// Module scope: the registration side effect this file renders through
+// (AGENTS.md 测试纪律 — never inside a hook).
+import './index';
+
+/** The text `SchemaErrorBoundary` renders when a widget throws during RENDER. */
+const ERROR_BOUNDARY_MARKER = 'failed to render';
+
+/** The card's verbatim repro value, for both channels. */
+const NOT_A_FUNCTION = 'NOT-A-FUNCTION';
+
+/** Two records in the CURRENT month, so the default month view shows them. */
+function currentMonthRecords() {
+ const now = new Date();
+ const day = (n: number) =>
+ new Date(now.getFullYear(), now.getMonth(), n, 10, 0, 0, 0).toISOString();
+ return [
+ { id: 'r1', title: 'Computed Standup', start: day(10) },
+ { id: 'r2', title: 'Computed Review', start: day(12) },
+ ];
+}
+
+function calendarRegion(): Element | null {
+ return document.body.querySelector('[role="region"][aria-label="Calendar"]');
+}
+
+/** Present only in the MONTH view — `TimeGridView` (week/day) has no such grid. */
+function monthGrid(): Element | null {
+ return document.body.querySelector('[role="grid"][aria-label="Calendar grid"]');
+}
+
+/** The header's date label, which `CalendarView` mirrors into the popover trigger. */
+function headerDateLabel(): string {
+ const trigger = document.body.querySelector('[aria-label^="Current date:"]');
+ return trigger?.getAttribute('aria-label') ?? '';
+}
+
+async function expectCalendarRendered() {
+ await waitFor(() => expect(calendarRegion()).not.toBeNull());
+ expect(document.body.textContent ?? '').not.toContain(ERROR_BOUNDARY_MARKER);
+}
+
+/**
+ * Run `act` and return every error report it produced, from all three channels
+ * an uncaught handler error can take.
+ *
+ * A handler error is NOT a render error, so `SchemaErrorBoundary` never sees it
+ * and the DOM stays intact — the only evidence is the report. Which channel
+ * carries it depends on the DOM implementation (a real browser and happy-dom
+ * both "report the exception" per the DOM spec, but a synchronous rethrow out of
+ * `dispatchEvent` is also a legal shape), so all three are collected and the
+ * assertion is on the UNION. That way this pin cannot go quietly green because
+ * an environment changed which channel it reports on.
+ */
+function clickErrors(act: () => void): string[] {
+ const seen: string[] = [];
+ const onWindowError = (event: Event) => {
+ const e = event as ErrorEvent;
+ seen.push(`window.error: ${e.message || String(e.error)}`);
+ };
+ const consoleError = vi
+ .spyOn(console, 'error')
+ .mockImplementation((...args: unknown[]) => {
+ const text = args.map((a) => (a instanceof Error ? a.message : String(a))).join(' ');
+ // React logs plenty of its own noise here; only genuine TypeErrors of the
+ // shape this card is about are evidence.
+ if (text.includes('is not a function')) seen.push(`console.error: ${text}`);
+ });
+ window.addEventListener('error', onWindowError);
+ try {
+ act();
+ } catch (err) {
+ seen.push(`thrown: ${err instanceof Error ? err.message : String(err)}`);
+ } finally {
+ window.removeEventListener('error', onWindowError);
+ consoleError.mockRestore();
+ }
+ return seen;
+}
+
+describe('calendar-view: an authored handler key can no longer crash a click (objectui#4453)', () => {
+ it('drops an authored `onEventClick` string written on the NODE — the click is a no-op', async () => {
+ const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
+ try {
+ render(
+ ,
+ );
+
+ // The calendar renders normally before AND after the fix — that is the
+ // hazard: nothing on screen distinguishes the two.
+ await expectCalendarRendered();
+ const event = await screen.findByRole('button', { name: 'Computed Standup' });
+
+ // Before the fix: `window.error: onEventClick is not a function`.
+ expect(clickErrors(() => fireEvent.click(event))).toEqual([]);
+ // Still a calendar afterwards, not a blank page.
+ expect(calendarRegion()).not.toBeNull();
+ } finally {
+ errors.mockRestore();
+ }
+ });
+
+ it('drops an authored `onEventClick` string written in the `props` CONTAINER', async () => {
+ const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
+ try {
+ render(
+ ,
+ );
+
+ await expectCalendarRendered();
+ const event = await screen.findByRole('button', { name: 'Computed Standup' });
+
+ expect(clickErrors(() => fireEvent.click(event))).toEqual([]);
+ expect(calendarRegion()).not.toBeNull();
+ } finally {
+ errors.mockRestore();
+ }
+ });
+
+ it('drops an authored `onAction` string — the SAME click, the same crash, the same answer', async () => {
+ const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
+ try {
+ render(
+ ,
+ );
+
+ await expectCalendarRendered();
+ const event = await screen.findByRole('button', { name: 'Computed Standup' });
+
+ expect(clickErrors(() => fireEvent.click(event))).toEqual([]);
+ } finally {
+ errors.mockRestore();
+ }
+ });
+
+ it('drops an authored `onDateClick` string — the whole handler family, not just the reported key', async () => {
+ const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
+ try {
+ render(
+ ,
+ );
+
+ await expectCalendarRendered();
+ // Any day cell: the month grid's cells carry `role="gridcell"`.
+ const cells = document.body.querySelectorAll('[role="gridcell"]');
+ expect(cells.length).toBeGreaterThan(0);
+
+ expect(clickErrors(() => fireEvent.click(cells[0]!))).toEqual([]);
+ } finally {
+ errors.mockRestore();
+ }
+ });
+
+ /* ── must-not-change: the host escape hatch ───────────────────────────── */
+
+ it('MUST-NOT-CHANGE: a host FUNCTION through the trailing props still fires with the event payload', async () => {
+ const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const onEventClick = vi.fn();
+ try {
+ render(
+ ,
+ );
+
+ await expectCalendarRendered();
+ fireEvent.click(await screen.findByRole('button', { name: 'Computed Standup' }));
+
+ expect(onEventClick).toHaveBeenCalledTimes(1);
+ expect(onEventClick).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 'r1', title: 'Computed Standup' }),
+ );
+ } finally {
+ errors.mockRestore();
+ }
+ });
+
+ it('MUST-NOT-CHANGE: a FUNCTION authored on the node (a programmatic host) still fires', async () => {
+ const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const onEventClick = vi.fn();
+ try {
+ render(
+ ,
+ );
+
+ await expectCalendarRendered();
+ fireEvent.click(await screen.findByRole('button', { name: 'Computed Standup' }));
+
+ expect(onEventClick).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 'r1', title: 'Computed Standup' }),
+ );
+ } finally {
+ errors.mockRestore();
+ }
+ });
+
+ it('MUST-NOT-CHANGE: with no host handler, the click still dispatches `onAction`', async () => {
+ const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const onAction = vi.fn();
+ try {
+ render(
+ ,
+ );
+
+ await expectCalendarRendered();
+ fireEvent.click(await screen.findByRole('button', { name: 'Computed Standup' }));
+
+ expect(onAction).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'event-click',
+ payload: expect.objectContaining({ id: 'r1', title: 'Computed Standup' }),
+ }),
+ );
+ } finally {
+ errors.mockRestore();
+ }
+ });
+
+ it('MUST-NOT-CHANGE: a host handler REPLACES the `onAction` dispatch (the old spread precedence)', async () => {
+ const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const onEventClick = vi.fn();
+ const onAction = vi.fn();
+ try {
+ render(
+ ,
+ );
+
+ await expectCalendarRendered();
+ fireEvent.click(await screen.findByRole('button', { name: 'Computed Standup' }));
+
+ // The trailing spread used to overwrite `onEventClick={handleEventClick}`,
+ // so a host handler has always suppressed the action dispatch. Restated
+ // explicitly by the fix, and pinned here so it stays a decision rather
+ // than a side effect of prop ordering.
+ expect(onEventClick).toHaveBeenCalledTimes(1);
+ expect(onAction).not.toHaveBeenCalled();
+ } finally {
+ errors.mockRestore();
+ }
+ });
+});
+
+describe('calendar-view: the rest of the forward set is consumed or declared (objectui#4453)', () => {
+ it('MUST-NOT-CHANGE: the declared `view` input still reaches CalendarView', async () => {
+ const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
+ try {
+ render(
+ ,
+ );
+
+ await expectCalendarRendered();
+ // `view` used to ride the raw spread; it is consumed explicitly now. The
+ // month grid is the discriminator — only `MonthView` renders one.
+ expect(monthGrid()).toBeNull();
+ } finally {
+ errors.mockRestore();
+ }
+ });
+
+ it('MUST-NOT-CHANGE: a node with no `view` still opens on the month grid', async () => {
+ const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
+ try {
+ render(
+ ,
+ );
+
+ await expectCalendarRendered();
+ expect(monthGrid()).not.toBeNull();
+ } finally {
+ errors.mockRestore();
+ }
+ });
+
+ it('treats an off-enum `view` as ABSENT — a usable month calendar, not a bodyless one', async () => {
+ const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
+ try {
+ render(
+ ,
+ );
+
+ await expectCalendarRendered();
+ expect(monthGrid()).not.toBeNull();
+ expect(await screen.findByRole('button', { name: 'Computed Standup' })).toBeTruthy();
+ } finally {
+ errors.mockRestore();
+ }
+ });
+
+ it('renders through an authored `locale` that `Intl` rejects, instead of throwing out of render', async () => {
+ const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
+ try {
+ render(
+ ,
+ );
+
+ await expectCalendarRendered();
+ expect(document.body.textContent ?? '').not.toContain('Incorrect locale information');
+ } finally {
+ errors.mockRestore();
+ }
+ });
+
+ it('MUST-NOT-CHANGE: a well-formed `locale` still reaches the header', async () => {
+ const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
+ try {
+ render(
+ ,
+ );
+
+ await expectCalendarRendered();
+ expect(headerDateLabel()).toContain('Dezember');
+ } finally {
+ errors.mockRestore();
+ }
+ });
+
+ it('MUST-NOT-CHANGE: `className` still lands on the calendar region', async () => {
+ const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
+ try {
+ render(
+ ,
+ );
+
+ await expectCalendarRendered();
+ // `className` is a declared registry input, consumed and forwarded — and
+ // `SchemaRenderer` sets it AFTER the node's `props` container, which is
+ // why an authored `props.className` was never an exposure here
+ // (objectui#4453's grading note).
+ expect(calendarRegion()?.getAttribute('class') ?? '').toContain('zz-authored-class');
+ } finally {
+ errors.mockRestore();
+ }
+ });
+
+ it('renders a node carrying the whole injected/authored tail at once', async () => {
+ const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
+ try {
+ render(
+ ,
+ );
+
+ await expectCalendarRendered();
+ // Still the real calendar with its computed events, not a degraded one.
+ expect(await screen.findByRole('button', { name: 'Computed Standup' })).toBeTruthy();
+ // The component's own accessible name is not overwritten by the node's.
+ expect(calendarRegion()?.getAttribute('aria-label')).toBe('Calendar');
+ } finally {
+ errors.mockRestore();
+ }
+ });
+});
diff --git a/packages/plugin-calendar/src/calendar-view-renderer.tsx b/packages/plugin-calendar/src/calendar-view-renderer.tsx
index 3bf6f02d4..8f5236b65 100644
--- a/packages/plugin-calendar/src/calendar-view-renderer.tsx
+++ b/packages/plugin-calendar/src/calendar-view-renderer.tsx
@@ -8,9 +8,153 @@
import { ComponentRegistry } from '@object-ui/core';
import type { CalendarViewSchema } from '@object-ui/types';
-import { CalendarView, type CalendarEvent } from './CalendarView';
+import { CalendarView, type CalendarEvent, type CalendarViewProps } from './CalendarView';
import React from 'react';
+/* ════════════════════════════════════════════════════════════════════════════
+ * The renderer boundary: consume or declare, never spread (objectui#4453)
+ *
+ * This renderer used to end in ``, where `props`
+ * was everything `SchemaRenderer` hands a registered widget: the node's own
+ * authored keys, the contents of its `props` container, the injected runtime
+ * props (`schema`, `bind`, `events`, `ariaLabel`/`ariaDescribedBy`/`role`,
+ * `data-obj-*`), and a host's trailing props — an UNBOUNDED set, spread onto a
+ * component whose props are a CLOSED list. Two of those collisions were
+ * user-reachable crashes on their own cards (objectui#4433 `events`,
+ * objectui#4452 `currentDate`), and this card is the third and worst: an
+ * authored `onEventClick: 'NOT-A-FUNCTION'` renders a perfectly normal calendar
+ * and then throws `onEventClick is not a function` on the first click — as an
+ * UNCAUGHT window error, because React does not route event-handler errors to
+ * `SchemaErrorBoundary`. The calendar keeps looking fine while its click
+ * handling is dead.
+ *
+ * The objectui#4425 phase-2 ruling (option 1, whitelist bounded by declaration)
+ * applied to this widget: **the forward set is exactly {@link CalendarViewProps},
+ * every key resolved to the type that prop declares; everything else is
+ * dropped.** No raw spread reaches `CalendarView`. `rest` below is READ for the
+ * declared keys and is never spread — that is the whole of this fix.
+ *
+ * A deny-list could not close this: the leak is the open tail of author-supplied
+ * keys, which no enumeration can finish. This list is finishable because
+ * `CalendarViewProps` declares it.
+ * ══════════════════════════════════════════════════════════════════════════ */
+
+/**
+ * The DECLARED host escape hatch: `CalendarView`'s callback surface, forwarded
+ * only when the value really is a function.
+ *
+ * One key, two very different producers, arriving through the SAME channel
+ * (objectui#4453):
+ *
+ * 1. an SDUI author writing JSON, whose value can never be a function — so it
+ * is always the crash above; and
+ * 2. a React host rendering ``,
+ * which the trailing-props spread makes work today and which this card
+ * forbids breaking. It is the component's genuine escape hatch, the same
+ * passthrough `MetricWidget` deliberately keeps.
+ *
+ * The key name cannot separate them; only the value's TYPE can. So the hatch is
+ * DECLARED here, with its declared type, and off-type input gets the one answer
+ * every other resolver in this file gives: dropped — the same answer as absent.
+ * That is the objectui#4435 declared-passthrough pattern, not a lenient
+ * consumer coercion (AGENTS.md #0.1): the discrimination lives in a declared
+ * contract at the renderer boundary, one key, one declared type, one answer.
+ *
+ * The whole family is listed rather than `onEventClick` alone because the defect
+ * is the family's, not the key's: an authored `onDateClick` / `onNavigate` /
+ * `onViewChange` / `onEventDrop` / `onTimeRangeSelect` / `onAddClick` string
+ * kills its own gesture in exactly the same way. Every one of them is a prop
+ * `CalendarView` declares, so nothing here widens the widget's surface — it
+ * narrows what may reach it — and `onEventClick` / `onDateClick` / `onViewChange`
+ * / `onNavigate` are additionally the four handlers this package's own docs
+ * publish as `calendar-view`'s API (`content/docs/plugins/plugin-calendar.mdx`,
+ * "CalendarView Schema API"). Dropping them would have removed a documented,
+ * working host path — which is exactly what objectui#4433's ruling refused to do
+ * silently.
+ */
+const HOST_CALLBACKS = [
+ 'onEventClick',
+ 'onDateClick',
+ 'onViewChange',
+ 'onNavigate',
+ 'onAddClick',
+ 'onEventDrop',
+ 'onTimeRangeSelect',
+] as const;
+
+type HostCallbacks = Pick;
+
+/**
+ * Read the declared callbacks out of the incoming props, keeping only the values
+ * whose type the hatch declares. Never a spread of the raw props: an authored
+ * key that is not on {@link HOST_CALLBACKS} cannot appear in the result at all.
+ */
+function pickHostCallbacks(incoming: Record): HostCallbacks {
+ const declared: Record = {};
+ for (const key of HOST_CALLBACKS) {
+ const raw = incoming[key];
+ if (typeof raw === 'function') declared[key] = raw;
+ }
+ return declared as HostCallbacks;
+}
+
+/** The view modes the registry input declares, and the only ones `CalendarView` renders. */
+const CALENDAR_VIEW_MODES = ['month', 'week', 'day'] as const;
+
+/**
+ * Resolve the declared `view` input against its declared enum.
+ *
+ * `view` is a registry input (`type: 'enum', enum: ['month','week','day']`) and
+ * a `CalendarView` prop of the same union, so it is CONSUMED here rather than
+ * carried by a spread. Off-enum input gets the resolver's one answer — dropped,
+ * i.e. the component's own `month` default — instead of today's silent breakage:
+ * `CalendarView` renders its body under `selectedView === "month" | "week" |
+ * "day"`, so a value outside the union rendered the header and NO calendar at
+ * all.
+ */
+function resolveAuthoredView(raw: unknown): CalendarViewProps['view'] {
+ return (CALENDAR_VIEW_MODES as readonly unknown[]).includes(raw)
+ ? (raw as CalendarViewProps['view'])
+ : undefined;
+}
+
+/**
+ * Resolve the `locale` hatch: a string, and one `Intl` will actually accept.
+ *
+ * Not a registry input — this is a host-only passthrough (`CalendarView` falls
+ * back to the ambient i18n language) — but it is a declared `CalendarViewProps`
+ * key, so it is forwarded, typed, rather than dropped. The type check is not
+ * `typeof raw === 'string'`: MEASURED on this tree, a structurally invalid tag
+ * throws out of the render, which is a second crash channel on the same widget —
+ * `new Date().toLocaleDateString('en_US')` (the underscore spelling a producer
+ * writes by accident) throws `RangeError: Incorrect locale information
+ * provided`, and so do `''`, `'123'` and `'a'`. `Intl.getCanonicalLocales`
+ * asks the one question that matters — will `Intl` take this tag — instead of a
+ * hand-rolled BCP-47 dialect. A well-formed tag nobody has data for (`zz-ZZ`)
+ * is NOT rejected here: `Intl` resolves it to its own default, which is the
+ * component's business, not this boundary's.
+ */
+function resolveAuthoredLocale(raw: unknown): string | undefined {
+ if (typeof raw !== 'string') return undefined;
+ try {
+ Intl.getCanonicalLocales(raw);
+ } catch {
+ return undefined;
+ }
+ return raw;
+}
+
+/**
+ * Resolve the `slotMinutes` hatch: the week/day grid's snap granularity, the
+ * knob the drag callbacks above are useless without (README, "Drag-and-Drop").
+ * A declared `CalendarViewProps` key, host-only like `locale`, forwarded only as
+ * the positive finite number the component divides an hour by — `0`, `NaN`,
+ * `-5` and `'30'` all get the absent-key answer, i.e. the component's default.
+ */
+function resolveAuthoredSlotMinutes(raw: unknown): number | undefined {
+ return typeof raw === 'number' && Number.isFinite(raw) && raw > 0 ? raw : undefined;
+}
+
/**
* Resolve the authored `currentDate` into the type `CalendarView` declares.
*
@@ -52,35 +196,44 @@ ComponentRegistry.register('calendar-view',
schema,
className,
onAction,
- // The authored SDUI `events` key, destructured out so the `{...props}`
- // spread below cannot overwrite the `CalendarEvent[]` computed from
- // `schema.data` (objectui#4433; the deny-list precedent is objectui#4357 /
- // PR #4428, where `SchemaRenderer`'s injected schema-shaped props are
- // stripped at the component's own signature).
- //
- // `events` is the ordinary action metadata of AGENTS.md section 4, legal on
- // ANY node, and `SchemaRenderer` forwards it as a plain prop — it is not on
- // that renderer's strip list. Both channels land here: the node's own
- // `events` key and a `props: { events }` container, since the renderer
- // spreads the container's contents too.
+ // The authored SDUI `events` key: DROPPED (objectui#4433). It is named here
+ // rather than merely left out of the forward set below, because it is the
+ // one key whose drop removes something an author could legitimately have
+ // written: `events` is the ordinary action metadata of AGENTS.md section 4,
+ // legal on ANY node, and `SchemaRenderer` forwards it as a plain prop — it
+ // is not on that renderer's strip list. Both channels land here, the node's
+ // own `events` key and a `props: { events }` container.
//
// Nothing is disabled by dropping it. No code in the renderer layer reads a
// node's `events` key — `SchemaRenderer` forwards it and nothing consumes
// it; this repo's action path is `properties.action` through `ActionRunner`.
// On this node type the key has never done anything but overwrite the
- // calendar: an OBJECT threw `events is not iterable` (the reported crash),
- // and an ARRAY silently replaced the computed calendar with itself. This
- // component's real action channel is `onAction` below, which is untouched.
+ // computed calendar: an OBJECT threw `events is not iterable` (the reported
+ // crash), and an ARRAY silently replaced the calendar with itself. This
+ // component's real action channel is `onAction` below.
events: _authoredEvents,
- // The declared `currentDate` input, destructured out for the same reason
- // and by the same pattern: a CONSUMED key must not also ride the spread.
- // Unlike `events` this one is not dropped — it is converted below and
- // passed on as the `Date` the component's prop type declares
- // (objectui#4452). Both authoring channels land here, the node's own
- // `currentDate` key and a `props: { currentDate }` container.
+ // The declared `currentDate` input: CONSUMED and converted below into the
+ // `Date` the component's prop type declares (objectui#4452). Both authoring
+ // channels land here, the node's own `currentDate` key and a
+ // `props: { currentDate }` container.
currentDate: authoredCurrentDate,
- ...props
- }: { schema: CalendarViewSchema; className?: string; onAction?: (action: any) => void; [key: string]: any }) => {
+ // The declared `view` input: CONSUMED and narrowed to its declared enum
+ // below (objectui#4453).
+ view: authoredView,
+ // Everything else. READ for declared keys, NEVER spread: this is the raw
+ // channel the old `{...props}` handed straight to `CalendarView`, and the
+ // reason an authored string could land on a function-typed prop.
+ ...rest
+ }: {
+ schema: CalendarViewSchema;
+ className?: string;
+ // Deliberately `unknown`, not `(action) => void`: this prop arrives on the
+ // same channel as everything else on this boundary, so the old signature
+ // was a type ASSERTION about authored JSON, not a fact. It is narrowed
+ // below.
+ onAction?: unknown;
+ [key: string]: unknown;
+ }) => {
// Transform schema data to CalendarEvent format
const events = React.useMemo(() => {
if (!schema.data || !Array.isArray(schema.data)) return [];
@@ -120,34 +273,67 @@ ComponentRegistry.register('calendar-view',
[authoredCurrentDate],
);
+ // The declared `view` input, narrowed to the enum it declares.
+ const view = resolveAuthoredView(authoredView);
+
+ // The declared host hatches, each kept only at its declared type. Read out
+ // of `rest`; `rest` itself never reaches `CalendarView`.
+ const hostCallbacks = pickHostCallbacks(rest);
+ const locale = resolveAuthoredLocale(rest.locale);
+ const slotMinutes = resolveAuthoredSlotMinutes(rest.slotMinutes);
+
+ // The action channel, under the same declared-type rule as the callbacks:
+ // `onAction` reaches this renderer through the very same props channel, so
+ // an authored `onAction: 'NOT-A-FUNCTION'` killed the very same click with
+ // the very same uncaught `onAction is not a function` (objectui#4453). A
+ // non-function is dropped — the same answer as absent, which is a calendar
+ // whose clicks simply dispatch nowhere.
+ const dispatchAction =
+ typeof onAction === 'function'
+ ? (onAction as (action: { type: string; payload: unknown }) => void)
+ : undefined;
+
const handleEventClick = (event: CalendarEvent) => {
- onAction?.({
+ dispatchAction?.({
type: 'event-click',
- payload: event
+ payload: event
});
};
-
+
const handleAddClick = () => {
// Standard "Create" action trigger
- onAction?.({
+ dispatchAction?.({
type: 'create',
payload: {}
});
};
+ // The forward set is exactly `CalendarViewProps` — nothing else can reach
+ // the component, because nothing is spread from `rest` (objectui#4453).
return (
);
}