From c01cdb9fc46c1a578e8e74ba59b9a8d1bc900d31 Mon Sep 17 00:00:00 2001 From: Alessandro Afloarei Date: Fri, 7 Aug 2026 10:29:23 +0200 Subject: [PATCH] =?UTF-8?q?fix:=201.1.1=20=E2=80=94=20a=20calendar=20with?= =?UTF-8?q?=20zero=20resources=20no=20longer=20crashes=20or=20renders=20bl?= =?UTF-8?q?ank?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects that only surface together, when a consumer mounts the scheduler with an empty resources array and non-default resourceFields. Downstream this took a production page down: the day view reloaded the browser and the week view rendered an empty grid. 1. Day's zero-resource placeholder was keyed by literal {id, text} while every reader of a resource resolves fields through resourceFields. A consumer that remaps them (e.g. {idField:'resourceid', textField:'name'}) got a placeholder no reader could see. Now built from resourceFields. 2. ResourceHeader dereferenced resource[textField] unguarded via .charAt(0), so the invisible placeholder became a TypeError. A render-phase throw is not contained by the library — React unmounts and the CONSUMER's error boundary takes over, which is how one malformed resource became a whole-page failure. Coalesced to ''; the declared `text: string` was a claim about consumer data this type cannot enforce. 3. tabMode was true whenever resourceViewMode was 'tabs', even with no resources — but Week short-circuits WithResources when resources are empty, so the tab card never exists. The bounded+tabMode rule targets `& > div:first-of-type` believing it to be that card, and instead stretched the sticky header grid to minHeight 100%, pushing the hour rows a full viewport below the fold. Now requires resources to exist, so an empty calendar falls through to the correct resourceCount<=1 stacking branch. Patch, not minor: no API surface changes, and all three are corrections to behaviour in a state that previously threw or mis-rendered. New ResourceHeader tests cover the resilient half; verified they fail without fix 2. Full suite 30/30, tsc and eslint clean, `npm run build` (which prepublishOnly runs) succeeds and all three fixes verified present in dist/index.js. Note: the Scheduler.tsx diff also carries a one-line prettier normalization that was already uncommitted in the working tree — it was in the hunk being touched, and the file is prettier-clean with it. Co-Authored-By: Claude Opus 5 (1M context) --- package.json | 2 +- src/components/common/ResourceHeader.test.tsx | 44 +++++++++++++++++++ src/components/common/ResourceHeader.tsx | 7 ++- src/components/scheduler/Scheduler.tsx | 12 +++-- src/views/day/Day.tsx | 13 ++++-- 5 files changed, 69 insertions(+), 9 deletions(-) create mode 100644 src/components/common/ResourceHeader.test.tsx diff --git a/package.json b/package.json index dbcc7ba..506f392 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@blade47/react-scheduler", "private": false, - "version": "1.1.0", + "version": "1.1.1", "description": "React scheduler component based on Material-UI & Dayjs", "type": "module", "main": "./dist/index.js", diff --git a/src/components/common/ResourceHeader.test.tsx b/src/components/common/ResourceHeader.test.tsx new file mode 100644 index 0000000..0b639f8 --- /dev/null +++ b/src/components/common/ResourceHeader.test.tsx @@ -0,0 +1,44 @@ +// @vitest-environment jsdom +import type { Scheduler } from '@/types.ts'; + +import { render } from '@testing-library/react'; +import { expect, describe, it } from 'vitest'; + +import { StoreProvider } from '../../store/provider.tsx'; +import { ResourceHeader } from './ResourceHeader.tsx'; + +// Regression for a crash that escaped the library entirely: ResourceHeader read +// `resource[resourceFields.textField]` and called `.charAt(0)` on it unguarded. A resource missing +// that field yields undefined, and a render-phase TypeError is NOT contained — React unmounts the +// tree and the CONSUMER's error boundary handles it. Downstream that meant a full page reload for +// a calendar with no resources configured, because Day's placeholder was keyed {id,text} while the +// consumer had remapped resourceFields. Both halves are fixed; this pins the resilient half, since +// a library must not take the host application down over one malformed resource. +function renderHeader(initial: Partial, resource: Record) { + return render( + + + + ); +} + +describe('ResourceHeader', () => { + const fields = { idField: 'resourceid', textField: 'name' }; + + it('renders without throwing when the resource has no value for the configured text field', () => { + expect(() => + renderHeader({ resourceFields: fields } as Partial, { resourceid: 'default' }) + ).not.toThrow(); + }); + + it('still renders the text and its avatar initial when the field IS present', () => { + // Guards the null-coalescing against over-reach: silencing the crash must not silence the name. + const { getByText } = renderHeader({ resourceFields: fields } as Partial, { + resourceid: 'room-1', + name: 'Aula Magna', + }); + + expect(getByText('Aula Magna')).toBeTruthy(); + expect(getByText('A')).toBeTruthy(); + }); +}); diff --git a/src/components/common/ResourceHeader.tsx b/src/components/common/ResourceHeader.tsx index 075975f..733c684 100644 --- a/src/components/common/ResourceHeader.tsx +++ b/src/components/common/ResourceHeader.tsx @@ -24,8 +24,13 @@ export const ResourceHeader = ({ resource }: Props) => { const theme = useTheme(); + // `?? ''` because the declared `text: string` is a claim about the CONSUMER's data, not something + // this type can enforce: a resource missing the configured textField yields undefined here, and + // `text` is dereferenced unguarded below (`.charAt(0)`). A render-phase throw does not degrade — + // React unmounts the tree and the consumer's error boundary takes over, so one misconfigured + // resource took down the whole page. An empty header is the correct failure mode for a library. const getResourceFields = (): LocalResourceFields => ({ - text: resource[resourceFields.textField], + text: resource[resourceFields.textField] ?? '', subtext: resource[resourceFields.subTextField || ''], avatar: resource[resourceFields.avatarField || ''], color: resource[resourceFields.colorField || ''], diff --git a/src/components/scheduler/Scheduler.tsx b/src/components/scheduler/Scheduler.tsx index 403721f..0fb1b13 100644 --- a/src/components/scheduler/Scheduler.tsx +++ b/src/components/scheduler/Scheduler.tsx @@ -69,11 +69,15 @@ export const SchedulerComponent = forwardRef((_, ref) => { // see DayTable), so it must never trigger the side-by-side row layout that week/month's // per-resource cards use. Counting day as >1 flipped the container to flex-row and broke // the header/body stacking. - resourceCount={ - view !== 'day' && resourceViewMode === 'default' ? resources.length : 1 - } + resourceCount={view !== 'day' && resourceViewMode === 'default' ? resources.length : 1} bounded={Boolean(boundedHeight)} - tabMode={resourceViewMode === 'tabs'} + // Only when tabs are actually RENDERED, not merely requested: Week short-circuits + // WithResources when there are no resources, so with `resourceViewMode: 'tabs'` and an + // empty list the tab card never exists — and the bounded+tabMode rule, which targets + // `& > div:first-of-type` believing it to be that card, hit the sticky header grid instead + // and stretched it to minHeight 100%, pushing the hour rows a full viewport out of sight. + // Falling through to the resourceCount<=1 branch is exactly right for an empty calendar. + tabMode={resourceViewMode === 'tabs' && resources.length > 0} sx={{ overflowX: view !== 'day' && resourceViewMode === 'default' && resources.length > 1 diff --git a/src/views/day/Day.tsx b/src/views/day/Day.tsx index 1dbd6d2..10d434a 100644 --- a/src/views/day/Day.tsx +++ b/src/views/day/Day.tsx @@ -7,7 +7,7 @@ import { useDayEvents } from '@/views/day/hooks/useDayEvents.ts'; import { DayGrid } from '@/views/day/components/DayGrid.tsx'; export const Day = () => { - const { selectedDate, resources, agenda, day, timeZone } = useStore(); + const { selectedDate, resources, agenda, day, timeZone, resourceFields } = useStore(); const selectedDayjs = dayjs(selectedDate); @@ -26,8 +26,15 @@ export const Day = () => { // Handle the case where there are no resources if (resources.length === 0) { - // Create a default resource to still show the day view - const defaultResource = [{ id: 'default', text: 'Default' }]; + // Create a default resource to still show the day view, keyed by the CONSUMER's resourceFields + // rather than literal id/text. Every reader of a resource looks its fields up through + // resourceFields, so a placeholder with hardcoded keys is invisible to all of them as soon as a + // consumer remaps the fields — ResourceHeader then read `resource[textField]` as undefined and + // threw on `.charAt(0)`, which React escalates out of the library into the consumer's error + // boundary. Reported downstream as "no rooms configured makes the day view reload the page". + const defaultResource = [ + { [resourceFields.idField]: 'default', [resourceFields.textField]: 'Default' }, + ]; return agenda ? (