diff --git a/.changeset/wise-donkeys-shave.md b/.changeset/wise-donkeys-shave.md new file mode 100644 index 000000000..b75b96e9d --- /dev/null +++ b/.changeset/wise-donkeys-shave.md @@ -0,0 +1,14 @@ +--- +"@object-ui/plugin-calendar": patch +--- + +`object-calendar` / `view:calendar`: the renderer now consumes or declares every prop it forwards, instead of spreading the authored node into `ObjectCalendar` + +One shared renderer serves both registrations, and it ended in a raw spread of everything `SchemaRenderer` hands a widget — the node's authored keys, its `props` container, the injected runtime props and a host's trailing props — onto a component whose props are a closed list. `ObjectCalendarProps` declares eight callbacks and a `locale`, so an authored value under any of those names landed on the declared prop, and an SDUI author writing JSON can never produce a function: + +- an authored `onDateClick` string threw `onDateClick is not a function` on an empty day-cell click, and an authored `onNavigate` string threw on **Next period** — both as *uncaught* window errors, because React does not route event-handler errors to `SchemaErrorBoundary`, so the calendar kept looking fine while that gesture was dead; +- an authored `locale: 'en_US'` (the underscore spelling a producer writes by accident) threw `RangeError: Incorrect locale information provided` out of render and took the whole calendar to the error boundary. + +The forward set is now exactly `ObjectCalendarProps`, each key resolved to the type that prop declares: the callback family is a declared, function-typed host escape hatch, `locale` is accepted only when `Intl.getCanonicalLocales` takes it, `data`/`loading` keep the parent pre-fetch path at their declared types, and everything else — including the open tail of authored keys — is dropped. + +Host-passed functions are unaffected: a React host's handlers, and `ListView`'s `onRowClick`, still reach the component exactly as before. diff --git a/packages/plugin-calendar/src/index.tsx b/packages/plugin-calendar/src/index.tsx index ff8104ce4..aeadcf0ee 100644 --- a/packages/plugin-calendar/src/index.tsx +++ b/packages/plugin-calendar/src/index.tsx @@ -41,9 +41,195 @@ const OBJECT_CALENDAR_DATA_SOURCE: ElementDataSourceMapping = { sort: true, }; +/* ════════════════════════════════════════════════════════════════════════════ + * The renderer boundary: consume or declare, never spread (objectui#4492) + * + * This renderer — registered TWICE, as `plugin-calendar:object-calendar` and as + * `view:calendar`, so one implementation serves both keys — used to end in + * `< ObjectCalendar schema={bound} dataSource={dataSource} {...props} />`, 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 (`events`, `ariaLabel`/`role`, `data-obj-*`, …) and a host's trailing + * props — an UNBOUNDED set, spread onto a component whose props are a CLOSED + * list. `ObjectCalendarProps` declares eight callbacks and a `locale`, so an + * authored value under any of those names landed on the declared prop, and an + * SDUI author writing JSON can never produce a function. + * + * MEASURED on the pre-fix tree (objectui#4492), all three uncaught or fatal: + * + * - `onDateClick: 'NOT-A-FUNCTION'` → clicking an empty day cell throws + * `onDateClick is not a function`. `ObjectCalendar` tests `if (onDateClick)` + * and a non-empty string is TRUTHY, so the guard hands the string to a call. + * - `onNavigate: 'NOT-A-FUNCTION'` → clicking **Next period** throws + * `onNavigate is not a function`; `?.` guards nullish, not non-callable. + * - `locale: 'en_US'` → `toLocaleDateString('en_US')` throws + * `RangeError: Incorrect locale information provided` out of RENDER, taking + * the whole calendar to `SchemaErrorBoundary`. + * + * The first two are worse than an error boundary: React does not route + * event-handler errors to `SchemaErrorBoundary`, so they surface as UNCAUGHT + * window errors while the calendar keeps looking fine and that gesture is dead. + * + * The objectui#4425 phase-2 ruling (option 1, whitelist bounded by declaration) + * applied to this renderer exactly as objectui#4453 applied it to the sibling + * `calendar-view` renderer next door: **the forward set is exactly + * {@link ObjectCalendarProps}, every key resolved to the type that prop + * declares; everything else is dropped.** `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 + * `ObjectCalendarProps` declares it — and dropping the tail costs nothing, + * because `ObjectCalendar` DESTRUCTURES a closed list and reads no other prop. + * ══════════════════════════════════════════════════════════════════════════ */ + +/** + * The DECLARED host escape hatch: `ObjectCalendar`'s callback surface, forwarded + * only when the value really is a function. + * + * One key, two very different producers arriving through the SAME channel: an + * SDUI author writing JSON, whose value can never be a function (so it is always + * one of the crashes above), and a React host, whose passthrough works today and + * must keep working. This is not hypothetical for this widget — `ListView` + * builds an `object-calendar` node carrying `onRowClick: navigation.handleClick` + * (`packages/plugin-list/src/ListView.tsx`, `baseProps`), a real FUNCTION on a + * real node key. The key name cannot separate the two producers; only the + * value's TYPE can. So the hatch is DECLARED here, and off-type input gets the + * one answer every other resolver in this file gives: dropped — the same answer + * as an absent key. That is the objectui#4435 declared-passthrough pattern, not + * a lenient consumer coercion (AGENTS.md #0.1). + * + * The whole family is listed rather than the two reported keys, because the + * defect is the family's: every one of these is a prop `ObjectCalendarProps` + * declares, so listing them narrows what may reach the component and widens + * nothing. + * + * `onEdit` and `onDelete` are declared by `ObjectCalendarProps` but are NOT + * destructured by the component (`ObjectCalendar.tsx` — the record drawer builds + * its own edit/delete handlers from `dataSource`), so forwarding them is inert + * today. They are listed anyway: the hatch is bounded by the DECLARATION, and a + * key that is declared, inert and silently dropped would be a second, quieter + * contract than the one this boundary states. Their inertness is a census + * observation, not a behaviour change — before and after this card, a value + * under either name does nothing. + */ +const HOST_CALLBACKS = [ + 'onEventClick', + 'onRowClick', + 'onDateClick', + 'onEdit', + 'onDelete', + 'onNavigate', + 'onViewChange', + 'onEventDrop', +] 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; +} + +/** + * Resolve the `locale` hatch: a string, and one `Intl` will actually accept. + * + * The type check is deliberately NOT `typeof raw === 'string'` — that is the + * objectui#4494 lesson, and this card re-measured it on this widget: a + * structurally invalid tag throws out of RENDER, so a typeof-string gate leaves + * the measured crash wide open. `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 declared `data` prop: the records a PARENT pre-fetched. + * + * `ObjectCalendar` gates this path on `Array.isArray(externalData)` and uses it + * to skip its own fetch, so an array is the only value that ever meant anything + * under this key. Forwarding it typed is behaviour-identical for every input + * (a non-array was already ignored) and keeps one rule at this boundary rather + * than an exception. + */ +function resolveExternalData(raw: unknown): ObjectCalendarProps['data'] { + return Array.isArray(raw) ? raw : undefined; +} + +/** + * Resolve the declared `loading` prop, which the component honours only + * alongside external `data`. A boolean or the absent-key answer. + */ +function resolveExternalLoading(raw: unknown): ObjectCalendarProps['loading'] { + return typeof raw === 'boolean' ? raw : undefined; +} + +/** + * Resolve a host-supplied `dataSource` ADAPTER arriving on the props channel. + * + * Two different things are spelled `dataSource` in this repo, and they collide + * by name: the spec's per-element `PageComponentSchema.dataSource` BINDING + * (`{ object, view, filter, … }`, plain metadata) and the runtime data-source + * ADAPTER. `SchemaRenderer` already strips the binding off the node so it cannot + * be spread as a prop, and deliberately preserves a host's explicit React + * `dataSource` prop, which arrives last — that host path is kept here, and kept + * at its old precedence (an explicit prop still wins over the context adapter). + * + * It is now type-checked by the adapter's call surface rather than accepted raw, + * so a binding-shaped object reaching this key through the node's `props` + * container falls back to the context adapter instead of shadowing it — the + * shape that produced `dataSource.find is not a function` in objectstack#5576. + * `find` is the method `ObjectCalendar` itself guards on before fetching. + */ +function resolveHostDataSource(raw: unknown): ObjectCalendarProps['dataSource'] { + return raw && typeof (raw as { find?: unknown }).find === 'function' + ? (raw as ObjectCalendarProps['dataSource']) + : undefined; +} + // Register object-calendar component -export const ObjectCalendarRenderer: React.FC<{ schema: any; [key: string]: any }> = ({ schema, ...props }) => { +export const ObjectCalendarRenderer: React.FC<{ schema: any; [key: string]: any }> = ({ + schema, + // The merged node className + SDUI scope class, set by `SchemaRenderer` AFTER + // the node's `props` container: CONSUMED and forwarded, as before. + className, + // Everything else. READ for declared keys, NEVER spread: this is the raw + // channel the old `{...props}` handed straight to `ObjectCalendar`, and the + // reason an authored string could land on a function-typed prop. + ...rest +}) => { const { dataSource } = useSchemaContext() || {}; + + // The declared host hatches, each kept only at its declared type. Read out of + // `rest`; `rest` itself never reaches `ObjectCalendar`. + const hostCallbacks = pickHostCallbacks(rest); + const locale = resolveAuthoredLocale(rest.locale); + const externalData = resolveExternalData(rest.data); + const externalLoading = resolveExternalLoading(rest.loading); + const hostDataSource = resolveHostDataSource(rest.dataSource); + // The spec's `PageComponentSchema.dataSource` binding (objectstack#6953): a // calendar authored with the binding and no `objectName` never fetched, and // rendered an empty month with no error. @@ -55,7 +241,25 @@ export const ObjectCalendarRenderer: React.FC<{ schema: any; [key: string]: any testId="object-calendar" errorTitle="This calendar’s data source could not be resolved" > - {(bound) => } + {/* + The forward set is exactly `ObjectCalendarProps` — nothing else can reach + the component, because nothing is spread from `rest` (objectui#4492). + The registry's own declared inputs (`objectName`, `calendar`, and the + flat `startDateField` / `titleField` / … spelling `ObjectView` and + `ListView` emit) are consumed through `schema`, never through this + channel, so none of them needs a forward. + */} + {(bound) => ( + + )} ); }; diff --git a/packages/plugin-calendar/src/object-calendar-renderer.propsContract.test.tsx b/packages/plugin-calendar/src/object-calendar-renderer.propsContract.test.tsx new file mode 100644 index 000000000..93948084b --- /dev/null +++ b/packages/plugin-calendar/src/object-calendar-renderer.propsContract.test.tsx @@ -0,0 +1,566 @@ +/** + * 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:object-calendar` / `view:calendar` — the shared renderer + * forwards only what it consumes or declares (objectui#4492; the objectui#4425 + * phase-2 whitelist contract applied to this renderer, exactly as objectui#4453 + * applied it to the sibling `calendar-view` renderer next door). + * + * ONE implementation is registered under BOTH keys, so one fix closes both and + * both are pinned here. + * + * The renderer used to end in `< ObjectCalendar schema={bound} … {...props} />`, + * where `props` was everything `SchemaRenderer` hands a registered widget. + * `ObjectCalendarProps` declares eight callbacks and a `locale`, so an authored + * value under any of those names landed on the declared prop — and an SDUI + * author writing JSON can never produce a function. The card measured three + * user-reachable failures, all pinned below: + * + * - authored `onDateClick` string → day-cell click throws UNCAUGHT + * - authored `onNavigate` string → Next-period click throws UNCAUGHT + * - authored `locale: 'en_US'` → RangeError out of RENDER, error boundary + * + * ## Why the handler cases are 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 that + * gesture is dead — nothing on screen says anything is wrong. The assertions + * therefore capture the window `error` event, a synchronous throw out of + * `fireEvent`, and `console.error` (see {@link clickErrors}), and assert on the + * UNION, 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, and not a hypothetical one: `ListView` builds + * an `object-calendar` node carrying `onRowClick: navigation.handleClick`, a + * real function on a real node key. It survives as a DECLARED, function-typed + * hatch. The declared `data` / `loading` pre-fetch path (`ObjectView`) and a + * well-formed `locale` survive the same way. + */ + +import { describe, it, expect, vi } from 'vitest'; +import React from 'react'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { SchemaRenderer, SchemaRendererProvider } 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'; + +/** + * The card's verbatim repro locale: the underscore spelling a producer writes by + * accident. `Intl` rejects it, so `toLocaleDateString` throws out of render. + */ +const BAD_LOCALE = 'en_US'; + +/** + * The flat calendar config `ObjectView` / `ListView` emit, which is what makes + * `getCalendarConfig` return non-null and the month grid render at all. + */ +const CALENDAR_FIELDS = { startDateField: 'start_at', titleField: 'name' }; + +/** 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', name: 'Computed Standup', start_at: day(10) }, + { id: 'r2', name: 'Computed Review', start_at: day(12) }, + ]; +} + +/** + * A node for either registration. `data` is the declared pre-fetch array, which + * makes the component skip its own fetch and render immediately. + */ +function calendarNode(type: string, extra: Record = {}) { + return { + type, + id: 'n', + objectName: 'accounts', + ...CALENDAR_FIELDS, + data: currentMonthRecords(), + ...extra, + } as never; +} + +/** + * Render a node through the real `SchemaRenderer`, inside the provider the + * renderer's own `useSchemaContext()` call requires. + * + * `dataSource: null` is deliberate: every node here carries the declared `data` + * array, so the component takes its pre-fetched path and never fetches. That + * keeps each case about the props boundary and nothing else. + * + * `hostProps` are `SchemaRenderer`'s TRAILING props — the React-host channel, + * spread onto the widget last, which is the path the host hatches must keep. + */ +function renderCalendar(schema: unknown, hostProps: Record = {}) { + return render( + + + , + ); +} + +function calendarRegion(): Element | null { + return document.body.querySelector('[role="region"][aria-label="Calendar"]'); +} + +/** 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; +} + +/** Any day cell: the month grid's cells carry `role="gridcell"`. */ +function dayCells(): NodeListOf { + return document.body.querySelectorAll('[role="gridcell"]'); +} + +describe('object-calendar: an authored handler key can no longer crash a gesture (objectui#4492)', () => { + it('drops an authored `onDateClick` string written on the NODE — the day-cell click is a no-op', async () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + // The card's repro, channel 1. `ObjectCalendar` tests `if (onDateClick)` + // and a non-empty string is TRUTHY, so the guard used to hand the string + // straight to a call. + renderCalendar(calendarNode('plugin-calendar:object-calendar', { + onDateClick: NOT_A_FUNCTION, + })); + + // The calendar renders normally before AND after the fix — that is the + // hazard: nothing on screen distinguishes the two. + await expectCalendarRendered(); + const cells = dayCells(); + expect(cells.length).toBeGreaterThan(0); + + // Before the fix: `window.error: onDateClick is not a function`. + expect(clickErrors(() => fireEvent.click(cells[0]!))).toEqual([]); + // Still a calendar afterwards, not a blank page. + expect(calendarRegion()).not.toBeNull(); + } finally { + errors.mockRestore(); + } + }); + + it('drops an authored `onDateClick` string written in the `props` CONTAINER', async () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + // Channel 2: `SchemaRenderer` spreads the container's contents as props + // too, so a fix that only handled the node's own key would leave this + // half live (objectui#4452's lesson). + renderCalendar(calendarNode('plugin-calendar:object-calendar', { + props: { onDateClick: NOT_A_FUNCTION }, + })); + + await expectCalendarRendered(); + const cells = dayCells(); + expect(cells.length).toBeGreaterThan(0); + + expect(clickErrors(() => fireEvent.click(cells[0]!))).toEqual([]); + expect(calendarRegion()).not.toBeNull(); + } finally { + errors.mockRestore(); + } + }); + + it('drops an authored `onNavigate` string written on the NODE — the Next-period click is a no-op', async () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + // The card's second repro. `onNavigate?.(date)` guards NULLISH, not + // non-callable, so a string sails past the optional call. + renderCalendar(calendarNode('plugin-calendar:object-calendar', { + onNavigate: NOT_A_FUNCTION, + })); + + await expectCalendarRendered(); + const next = await screen.findByRole('button', { name: 'Next period' }); + + // Before the fix: `window.error: onNavigate is not a function`. + expect(clickErrors(() => fireEvent.click(next))).toEqual([]); + expect(calendarRegion()).not.toBeNull(); + } finally { + errors.mockRestore(); + } + }); + + it('drops an authored `onNavigate` string written in the `props` CONTAINER', async () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + renderCalendar(calendarNode('plugin-calendar:object-calendar', { + props: { onNavigate: NOT_A_FUNCTION }, + })); + + await expectCalendarRendered(); + const next = await screen.findByRole('button', { name: 'Next period' }); + + expect(clickErrors(() => fireEvent.click(next))).toEqual([]); + } 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 { + // The card's third repro, and the only one that is fatal at RENDER time: + // `toLocaleDateString('en_US')` throws `RangeError: Incorrect locale + // information provided`, taking the whole calendar to the boundary. + renderCalendar(calendarNode('plugin-calendar:object-calendar', { + locale: BAD_LOCALE, + })); + + // Post-fix: the key is dropped, so the component's own default locale + // applies and the header renders a real date label. + await expectCalendarRendered(); + expect(headerDateLabel()).not.toBe(''); + } finally { + errors.mockRestore(); + } + }); + + it('renders through a rejected `locale` written in the `props` CONTAINER', async () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + renderCalendar(calendarNode('plugin-calendar:object-calendar', { + props: { locale: BAD_LOCALE }, + })); + + await expectCalendarRendered(); + expect(headerDateLabel()).not.toBe(''); + } finally { + errors.mockRestore(); + } + }); + + /* ── `onEventClick`: the declared-hatch rule, with its provenance stated ─── */ + + it('drops an authored `onEventClick` string under the DEFAULT navigation — pinned as a contract, not as a crash repro', async () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + // PROVENANCE (objectui#4492, recorded honestly): the filing could NOT + // reproduce a crash on this key. `ObjectCalendar` only calls the parent + // handler when the local navigation is NOT an overlay (`if + // (!navIsOverlay)`), and the default navigation config is a DRAWER — an + // overlay — so the call is skipped and the authored string is never + // reached. This case therefore asserts the CONTRACT (the key is dropped + // and the click is clean) and deliberately claims no pre-fix crash: it is + // green on both sides of the fix, by construction. + renderCalendar(calendarNode('plugin-calendar:object-calendar', { + onEventClick: NOT_A_FUNCTION, + })); + + await expectCalendarRendered(); + const event = await screen.findByRole('button', { name: 'Computed Standup' }); + + expect(clickErrors(() => fireEvent.click(event))).toEqual([]); + } finally { + errors.mockRestore(); + } + }); + + it('drops an authored `onEventClick` string when navigation is NOT an overlay — the reachability the filing left open', async () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + // PROVENANCE, part 2 — this card's own new measurement, NOT the filing's. + // The filing established the exposure in the code path but explicitly + // could not establish its reachability, so it claimed no crash. This case + // supplies the missing condition: `navigation: { mode: 'none' }` is the + // smallest non-overlay configuration — `navIsOverlay` is false, and + // `useNavigationOverlay`'s own `handleClick` returns early for `none`, so + // no router is involved — which makes `onEventClick?.(event.data)` run. + // + // MEASURED on the pre-fix renderer, verbatim: + // window.error: onEventClick is not a function + // console.error: onEventClick is not a function + // + // So the exposure is real and reachable, and the answer to the filing's + // open question is: it depends on the navigation mode, exactly as the + // filing suspected. The DEFAULT (drawer) config above stays a + // contract-only pin, green on both sides. + renderCalendar(calendarNode('plugin-calendar:object-calendar', { + navigation: { mode: 'none' }, + onEventClick: NOT_A_FUNCTION, + })); + + await expectCalendarRendered(); + const event = await screen.findByRole('button', { name: 'Computed Standup' }); + + expect(clickErrors(() => fireEvent.click(event))).toEqual([]); + } finally { + errors.mockRestore(); + } + }); + + /* ── the SECOND registration: one implementation, one fix ─────────────── */ + + it('`view:calendar` — the twin registration — drops the same authored `onDateClick` string', async () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + // One implementation is registered under both keys, so this is the same + // defect reached through the other door. Pinned so a future change that + // fixes only one registration cannot pass. + renderCalendar(calendarNode('view:calendar', { + onDateClick: NOT_A_FUNCTION, + })); + + await expectCalendarRendered(); + const cells = dayCells(); + expect(cells.length).toBeGreaterThan(0); + + expect(clickErrors(() => fireEvent.click(cells[0]!))).toEqual([]); + } finally { + errors.mockRestore(); + } + }); + + it('`view:calendar` renders through a `locale` that `Intl` rejects', async () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + renderCalendar(calendarNode('view:calendar', { + locale: BAD_LOCALE, + })); + + await expectCalendarRendered(); + expect(headerDateLabel()).not.toBe(''); + } finally { + errors.mockRestore(); + } + }); + + /* ── must-not-change: the host escape hatches ─────────────────────────── */ + + it('MUST-NOT-CHANGE: a host FUNCTION `onDateClick` through the trailing props still fires', async () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + const onDateClick = vi.fn(); + try { + // The host path the card forbids breaking: `SchemaRenderer` spreads its + // own extra props onto the component last. + renderCalendar(calendarNode('plugin-calendar:object-calendar'), { onDateClick }); + + await expectCalendarRendered(); + fireEvent.click(dayCells()[0]!); + + expect(onDateClick).toHaveBeenCalledTimes(1); + expect(onDateClick.mock.calls[0]![0]).toBeInstanceOf(Date); + } finally { + errors.mockRestore(); + } + }); + + it('MUST-NOT-CHANGE: a FUNCTION `onDateClick` authored on the node (a programmatic host) still fires', async () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + const onDateClick = vi.fn(); + try { + // Not JSON-authorable, but reachable from a host that builds the node in + // TS. The hatch discriminates on the VALUE's type, not on which channel + // it arrived through, so this keeps working. + renderCalendar(calendarNode('plugin-calendar:object-calendar', { + onDateClick, + })); + + await expectCalendarRendered(); + fireEvent.click(dayCells()[0]!); + + expect(onDateClick).toHaveBeenCalledTimes(1); + } finally { + errors.mockRestore(); + } + }); + + it('MUST-NOT-CHANGE: a host FUNCTION `onNavigate` still fires on Next period', async () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + const onNavigate = vi.fn(); + try { + renderCalendar(calendarNode('plugin-calendar:object-calendar'), { onNavigate }); + + await expectCalendarRendered(); + fireEvent.click(await screen.findByRole('button', { name: 'Next period' })); + + expect(onNavigate).toHaveBeenCalledTimes(1); + expect(onNavigate.mock.calls[0]![0]).toBeInstanceOf(Date); + } finally { + errors.mockRestore(); + } + }); + + it('MUST-NOT-CHANGE: `ListView`s `onRowClick` FUNCTION on the node still reaches the component', async () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + const onRowClick = vi.fn(); + try { + // The real host path measured in `packages/plugin-list/src/ListView.tsx`: + // its `baseProps` puts `onRowClick: navigation.handleClick` on the + // `object-calendar` node it builds. Observable only when navigation is + // not an overlay, which is the same condition the component applies to + // the inherited handler. + renderCalendar(calendarNode('plugin-calendar:object-calendar', { + navigation: { mode: 'none' }, + onRowClick, + })); + + await expectCalendarRendered(); + fireEvent.click(await screen.findByRole('button', { name: 'Computed Standup' })); + + expect(onRowClick).toHaveBeenCalledTimes(1); + expect(onRowClick.mock.calls[0]![0]).toEqual( + expect.objectContaining({ id: 'r1', name: 'Computed Standup' }), + ); + } finally { + errors.mockRestore(); + } + }); + + it('MUST-NOT-CHANGE: a host FUNCTION `onEventClick` still fires when navigation is not an overlay', async () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + const onEventClick = vi.fn(); + try { + renderCalendar(calendarNode('plugin-calendar:object-calendar', { + navigation: { mode: 'none' }, + onEventClick, + })); + + await expectCalendarRendered(); + fireEvent.click(await screen.findByRole('button', { name: 'Computed Standup' })); + + expect(onEventClick).toHaveBeenCalledWith( + expect.objectContaining({ id: 'r1', name: 'Computed Standup' }), + ); + } finally { + errors.mockRestore(); + } + }); + + it('MUST-NOT-CHANGE: a well-formed `locale` still reaches the component', async () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + renderCalendar(calendarNode('plugin-calendar:object-calendar', { + locale: 'de-DE', + })); + + await expectCalendarRendered(); + // German month names in the header prove the tag was forwarded, not + // dropped along with the rejected ones. + const label = headerDateLabel(); + expect(label).not.toBe(''); + expect( + /Januar|Februar|März|April|Mai|Juni|Juli|August|September|Oktober|November|Dezember/.test( + label, + ), + ).toBe(true); + } finally { + errors.mockRestore(); + } + }); + + it('MUST-NOT-CHANGE: both registrations still render a normal calendar with no authored extras', async () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const { unmount } = renderCalendar(calendarNode('plugin-calendar:object-calendar')); + await expectCalendarRendered(); + expect(await screen.findByRole('button', { name: 'Computed Standup' })).toBeTruthy(); + unmount(); + + renderCalendar(calendarNode('view:calendar')); + await expectCalendarRendered(); + expect(await screen.findByRole('button', { name: 'Computed Standup' })).toBeTruthy(); + } finally { + errors.mockRestore(); + } + }); + + it('MUST-NOT-CHANGE: the declared `data` / `loading` pre-fetch path still reaches the component', async () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + // `ObjectView` pre-fetches and hands the rows down; the component gates + // that path on `Array.isArray(data)` and skips its own fetch. Every case + // in this file depends on it, and `registration.test.tsx` pins the + // renderer-level passthrough — this pins the rendered consequence. + renderCalendar(calendarNode('plugin-calendar:object-calendar'), { loading: false }); + + await expectCalendarRendered(); + expect(await screen.findByRole('button', { name: 'Computed Review' })).toBeTruthy(); + } finally { + errors.mockRestore(); + } + }); + + it('MUST-NOT-CHANGE: the node className still lands on the calendar container', async () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + renderCalendar(calendarNode('plugin-calendar:object-calendar', { + className: 'os-calendar-probe', + })); + + await expectCalendarRendered(); + expect(document.body.querySelector('.os-calendar-probe')).not.toBeNull(); + } finally { + errors.mockRestore(); + } + }); +});