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 ( ); }