diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..8390f62 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,27 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: tests-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + - run: npm ci + - run: npm test + timeout-minutes: 5 diff --git a/README.md b/README.md index db36dd9..c5a8950 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,16 @@ export function Agenda() { ## Types -The package exports `ForceCalendarProps`, `ForceCalendarHandle`, `CalendarEvent`, `CalendarView`, `VisibleRange`, `EventsSetOptions`, `EventsSetResult`, `ForceCalendarElement`, `ForceCalendarEventMap` and one `*Detail` type per callback. It also declares `forcecal-main` in `JSX.IntrinsicElements` and `HTMLElementTagNameMap`, so the raw element is type-checked if you render or query it yourself. +The package exports `ForceCalendarProps`, `ForceCalendarHandle`, `CalendarEvent`, `CalendarView`, `VisibleRange`, `EventsSetOptions`, `EventsSetResult`, `ForceCalendarElement`, `ForceCalendarEventMap` and one `*Detail` type per callback. It also declares `forcecal-main` in `JSX.IntrinsicElements`, so the raw element is type-checked in JSX. + +The global `HTMLElementTagNameMap` mapping belongs to `@forcecalendar/interface` 1.7 and newer. Import its element type when using the raw DOM API. The adapter keeps its exported structural types, including plain `CalendarEvent` inputs, without redeclaring the global mapping: + +```ts +import type { ForceCalendarElement } from '@forcecalendar/interface'; + +const element: ForceCalendarElement = document.createElement('forcecal-main'); +``` + +With interface 1.6, use the adapter's exported type explicitly for direct DOM access: `document.querySelector('forcecal-main')`. The React component and its ref keep the same types across supported interface versions. Docs: [docs.forcecalendar.org](https://docs.forcecalendar.org) ยท License: [MIT](LICENSE) diff --git a/package-lock.json b/package-lock.json index 30f9f2f..73c2ddc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,8 @@ "version": "0.3.0", "license": "MIT", "devDependencies": { - "@forcecalendar/core": "^2.5.0", - "@forcecalendar/interface": "^1.6.0", + "@forcecalendar/core": "^2.5.5", + "@forcecalendar/interface": "^1.8.0", "@types/react": "^19", "jsdom": "^29.1.1", "react": "^19", @@ -18,8 +18,8 @@ "typescript": "^5" }, "peerDependencies": { - "@forcecalendar/core": ">=2.0.0", - "@forcecalendar/interface": ">=1.2.0", + "@forcecalendar/core": ">=2.0.0 <3", + "@forcecalendar/interface": ">=1.6.0 <2", "react": ">=18" } }, @@ -246,16 +246,16 @@ } }, "node_modules/@forcecalendar/core": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@forcecalendar/core/-/core-2.5.0.tgz", - "integrity": "sha512-gldF0NbYiU4vhxwDABvdVGvjmKkfI80bU86XI1RuBLhCI2O8wTES/Ot/spaa2i7l2Hi4M2hbNhelrs72ngf30w==", + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/@forcecalendar/core/-/core-2.5.5.tgz", + "integrity": "sha512-6pfDpWs46VvNc4WLV8ahOEYrFWRqOq883bdMCwkfgNOk3CSTY/RorGBYt/MYNXCesAN0q+4n7tpWVsg2Q1UYkw==", "dev": true, "license": "MIT" }, "node_modules/@forcecalendar/interface": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@forcecalendar/interface/-/interface-1.6.0.tgz", - "integrity": "sha512-DUtP5LJ/FfJedh5fkOKV2J4e40cFAPdxbQt8FE+Zk/fOtP5Z8halARPTYFIYSHwRcou20GOYQK3KhuGENpBDoA==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@forcecalendar/interface/-/interface-1.8.0.tgz", + "integrity": "sha512-yqLkXeMvx6zWFxQ6W50Bo8NHS7XOiuym29mgT+joHY7wRg0kN6fUtrTO2XTN95S8Ot2VdPQGCvZQ61U0jn7IQA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -569,9 +569,9 @@ } }, "node_modules/undici": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", - "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 5030a89..90c06fb 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ ], "scripts": { "build": "tsc -p tsconfig.json", - "test": "npm run build && tsc -p tsconfig.test.json --noEmit && node --test \"test/*.mjs\"" + "test": "npm run build && tsc -p tsconfig.test.json --noEmit && tsc -p tsconfig.test.nodenext.json --noEmit && node --test \"test/*.mjs\"" }, "peerDependencies": { "@forcecalendar/core": ">=2.0.0 <3", @@ -43,8 +43,8 @@ "react": ">=18" }, "devDependencies": { - "@forcecalendar/core": "^2.5.0", - "@forcecalendar/interface": "^1.6.0", + "@forcecalendar/core": "^2.5.5", + "@forcecalendar/interface": "^1.8.0", "@types/react": "^19", "jsdom": "^29.1.1", "react": "^19", diff --git a/src/dom-types.ts b/src/dom-types.ts index 9e8885e..66e8f2f 100644 --- a/src/dom-types.ts +++ b/src/dom-types.ts @@ -4,8 +4,9 @@ * (methods, `events` accessor and the `calendar-*` DOM events) so that the * adapter and its consumers can talk to the element without `any`. * - * Note: @forcecalendar/interface does not declare these itself yet; once it - * does, this file becomes a re-export. + * These structural types remain available for interface 1.6 and plain event + * inputs. Newer interfaces own the global DOM tag mapping; redeclaring it here + * would conflict with their canonical element type. */ export type CalendarView = 'month' | 'week' | 'day'; @@ -98,9 +99,3 @@ export interface ForceCalendarElement extends HTMLElement { options?: boolean | EventListenerOptions ): void; } - -declare global { - interface HTMLElementTagNameMap { - 'forcecal-main': ForceCalendarElement; - } -} diff --git a/src/index.tsx b/src/index.tsx index 2c89dcc..9772017 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -17,7 +17,7 @@ import type { ForceCalendarElement, ForceCalendarEventMap, VisibleRange, -} from './dom-types'; +} from './dom-types.js'; export type { CalendarEvent, @@ -27,7 +27,7 @@ export type { ForceCalendarElement, ForceCalendarEventMap, VisibleRange, -} from './dom-types'; +} from './dom-types.js'; const TAG = 'forcecal-main'; const MIN_INTERFACE_VERSION = '1.6.0'; @@ -307,12 +307,22 @@ export const ForceCalendar = forwardRef let rangeChangeSeen = false; let cancelled = false; + let initialRangeTimer: ReturnType | undefined; + const cancelInitialRange = () => { + if (initialRangeTimer !== undefined) { + clearTimeout(initialRangeTimer); + initialRangeTimer = undefined; + } + }; // Attach synchronously, before the import: an element that upgrades while // the interface is loading dispatches its first events right away. const listeners = EVENT_MAP.map(([eventName, callbackName]) => { const listener = (e: Event) => { - if (eventName === 'calendar-range-change') rangeChangeSeen = true; + if (eventName === 'calendar-range-change') { + rangeChangeSeen = true; + cancelInitialRange(); + } const callback = callbacks.current[callbackName] as ((detail: unknown) => void) | undefined; callback?.((e as CustomEvent).detail ?? {}); }; @@ -322,15 +332,19 @@ export const ForceCalendar = forwardRef whenDefined().then( () => { - // The element's own first calendar-range-change fires during upgrade, - // which can happen before this effect when the interface was already - // loaded. Report the initial window so consumers can fetch data for it. - const onRangeChange = callbacks.current.onRangeChange; - if (cancelled || rangeChangeSeen || !onRangeChange) return; - const range = typeof el.getVisibleRange === 'function' ? el.getVisibleRange() : null; - if (!range) return; - rangeChangeSeen = true; - onRangeChange({ ...range, ...readViewState(el, range) }); + if (cancelled || rangeChangeSeen) return; + // Interface 1.7 defers its initial announcement until the next task. + // Give that event priority before seeding a range for older interfaces, + // or for an announcement that happened before our listeners attached. + initialRangeTimer = setTimeout(() => { + initialRangeTimer = undefined; + const onRangeChange = callbacks.current.onRangeChange; + if (cancelled || rangeChangeSeen || !onRangeChange) return; + const range = typeof el.getVisibleRange === 'function' ? el.getVisibleRange() : null; + if (!range) return; + rangeChangeSeen = true; + onRangeChange({ ...range, ...readViewState(el, range) }); + }, 0); }, error => { if (!cancelled) reportLoadFailure(error); @@ -339,6 +353,7 @@ export const ForceCalendar = forwardRef return () => { cancelled = true; + cancelInitialRange(); for (const [eventName, listener] of listeners) { el.removeEventListener(eventName, listener); } diff --git a/test/dom.mjs b/test/dom.mjs index 2804d7e..37959b5 100644 --- a/test/dom.mjs +++ b/test/dom.mjs @@ -34,7 +34,7 @@ for (const key of [ globalThis.IS_REACT_ACT_ENVIRONMENT = true; // react-dom must see the DOM globals when it is first evaluated. -const { createElement, act, createRef } = await import('react'); +const { createElement, act, createRef, StrictMode } = await import('react'); const { createRoot } = await import('react-dom/client'); const { ForceCalendar } = await import('../dist/index.js'); @@ -53,6 +53,7 @@ const MAPPED_EVENTS = [ class StubCalendar extends dom.window.HTMLElement { /** When set, the stub announces its range on connect like the real element does on first render. */ static announceRangeOnConnect = false; + static deferRangeOnConnect = false; constructor() { super(); @@ -66,13 +67,15 @@ class StubCalendar extends dom.window.HTMLElement { connectedCallback() { if (StubCalendar.announceRangeOnConnect) { const { view, currentDate } = this.stateManager.getState(); - this.dispatchEvent( + const announce = () => this.dispatchEvent( new dom.window.CustomEvent('calendar-range-change', { detail: { ...this.getVisibleRange(), view, date: currentDate, announcedBy: 'element' }, bubbles: true, composed: true, }) ); + if (StubCalendar.deferRangeOnConnect) setTimeout(announce, 0); + else announce(); } } addEventListener(type, listener, options) { @@ -132,8 +135,11 @@ class StubCalendar extends dom.window.HTMLElement { } } -before(() => { +before(async () => { dom.window.customElements.define('forcecal-main', StubCalendar); + // Make timing tests deterministic: loading the interface is no longer racing + // the stub's announcement. The stub still owns the registered element. + await import('@forcecalendar/interface'); }); const mounted = []; @@ -145,14 +151,18 @@ afterEach(async () => { } }); -async function mount(props) { +async function mount(props, { strict = false } = {}) { const container = dom.window.document.createElement('div'); dom.window.document.body.append(container); const root = createRoot(container); mounted.push({ root, container }); - await act(() => root.render(createElement(ForceCalendar, props))); + const render = nextProps => { + const calendar = createElement(ForceCalendar, nextProps); + return strict ? createElement(StrictMode, null, calendar) : calendar; + }; + await act(() => root.render(render(props))); const el = container.querySelector('forcecal-main'); - const update = nextProps => act(() => root.render(createElement(ForceCalendar, nextProps))); + const update = nextProps => act(() => root.render(render(nextProps))); const unmount = async () => { await act(() => root.unmount()); mounted.splice(mounted.findIndex(m => m.root === root), 1); @@ -361,3 +371,81 @@ test('does not call onRangeChange at all when it is not provided', async () => { await settle(); assert.equal(el.calls.some(c => c[0] === 'getVisibleRange'), false); }); + +for (const strict of [false, true]) { + test(`deferred element announcement wins over readiness fallback${strict ? ' in StrictMode' : ''}`, async t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + StubCalendar.announceRangeOnConnect = true; + StubCalendar.deferRangeOnConnect = true; + try { + const ranges = []; + const { el } = await mount({ onRangeChange: detail => ranges.push(detail) }, { strict }); + assert.equal(ranges.length, 0, 'readiness must not synthesize before deferred DOM events'); + await act(() => t.mock.timers.runAll()); + assert.equal(ranges.length, 1); + assert.equal(ranges[0].announcedBy, 'element', 'the real element event takes priority'); + assert.equal(el.calls.filter(c => c[0] === 'getVisibleRange').length, 1, 'no synthetic range read'); + for (const [type] of MAPPED_EVENTS) assert.equal(el.listenerCounts.get(type), 1); + + const repeated = { ...ranges[0] }; + dispatch(el, 'calendar-range-change', repeated); + dispatch(el, 'calendar-range-change', repeated); + assert.equal(ranges.length, 3, 'later equal range events are not suppressed'); + assert.equal(ranges[1], repeated); + assert.equal(ranges[2], repeated); + } finally { + StubCalendar.announceRangeOnConnect = false; + StubCalendar.deferRangeOnConnect = false; + } + }); +} + +test('StrictMode replays effects without duplicating the legacy readiness fallback', async t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const ranges = []; + await mount({ onRangeChange: detail => ranges.push(detail) }, { strict: true }); + assert.equal(ranges.length, 0); + await act(() => t.mock.timers.runAll()); + assert.equal(ranges.length, 1); +}); + +test('cancels a pending fallback on unmount', async t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const ranges = []; + const { el, unmount } = await mount({ onRangeChange: detail => ranges.push(detail) }); + assert.equal(ranges.length, 0); + await unmount(); + await act(() => t.mock.timers.runAll()); + assert.equal(ranges.length, 0); + assert.equal(el.calls.some(c => c[0] === 'getVisibleRange'), false); +}); + +test('pending fallback reads the latest callback after a prop update', async t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const seen = []; + const { update } = await mount({ onRangeChange: () => seen.push('first') }); + await update({ onRangeChange: () => seen.push('second') }); + await act(() => t.mock.timers.runAll()); + assert.deepEqual(seen, ['second']); +}); + +test('unmounting while definition is pending never schedules a callback', async t => { + const registry = dom.window.customElements; + let release; + const defined = new Promise(resolve => (release = resolve)); + Object.defineProperty(globalThis, 'customElements', { + value: { whenDefined: () => defined }, configurable: true, writable: true, + }); + try { + const ranges = []; + const { el, unmount } = await mount({ onRangeChange: detail => ranges.push(detail) }); + await unmount(); + t.mock.timers.enable({ apis: ['setTimeout'] }); + await act(async () => { release(); await defined; }); + await act(() => t.mock.timers.runAll()); + assert.deepEqual(ranges, []); + assert.equal(el.calls.some(c => c[0] === 'getVisibleRange'), false); + } finally { + Object.defineProperty(globalThis, 'customElements', { value: registry, configurable: true, writable: true }); + } +}); diff --git a/test/fixtures/real-interface.mjs b/test/fixtures/real-interface.mjs new file mode 100644 index 0000000..102a761 --- /dev/null +++ b/test/fixtures/real-interface.mjs @@ -0,0 +1,87 @@ +/** Isolated browser realm for real interface cold/warm and StrictMode checks. */ +import assert from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; + +const warm = process.argv[2] === 'warm'; +const strict = process.argv[3] === 'strict'; +const dom = new JSDOM('', { + url: 'http://localhost/', + pretendToBeVisual: true, +}); +for (const key of [ + 'window', 'document', 'HTMLElement', 'Element', 'Node', 'customElements', + 'CustomEvent', 'Event', 'navigator', 'getComputedStyle', + 'requestAnimationFrame', 'cancelAnimationFrame', +]) { + Object.defineProperty(globalThis, key, { + value: dom.window[key], configurable: true, writable: true, + }); +} +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const { createElement, createRef, act, StrictMode } = await import('react'); +const { createRoot } = await import('react-dom/client'); +const { ForceCalendar } = await import('../../dist/index.js'); +if (warm) await import('@forcecalendar/interface'); +assert.equal(Boolean(customElements.get('forcecal-main')), warm); + +const container = document.createElement('div'); +document.body.append(container); +const root = createRoot(container); +const ref = createRef(); +const ranges = []; +let element; +const props = { + ref, + view: 'month', + date: '2026-03-04', + events: [{ id: 'plain', title: 'Snapshot', start: '2026-03-04T12:00:00Z' }], + onRangeChange: detail => ranges.push(detail), +}; +const render = nextProps => { + const calendar = createElement(ForceCalendar, nextProps); + return strict ? createElement(StrictMode, null, calendar) : calendar; +}; + +try { + await act(() => root.render(render(props))); + await act(() => ref.current.whenReady()); + // All announcement/fallback timers are queued by readiness. Drain two task + // turns rather than sleeping for a timing-dependent arbitrary interval. + for (let i = 0; i < 2; i++) { + await act(() => new Promise(resolve => setTimeout(resolve, 0))); + } + element = ref.current.element; + assert.ok(element instanceof customElements.get('forcecal-main')); + assert.equal(ranges.length, 1, 'one initial callback with the real interface'); + assert.deepEqual( + { start: ranges[0].start, end: ranges[0].end }, + ref.current.getVisibleRange() + ); + assert.equal(ranges[0].view, 'month'); + assert.ok(ranges[0].date instanceof Date); + assert.deepEqual(ref.current.getEvents().map(event => event.id), ['plain']); + + const first = ranges[0]; + await act(() => ref.current.next()); + assert.equal(ranges.length, 2, 'navigation callback is preserved'); + assert.notEqual(ranges[1].start.getTime(), first.start.getTime()); + await act(() => ref.current.previous()); + assert.equal(ranges.length, 3, 'returning to the initial range is not suppressed'); + assert.equal(ranges[2].start.getTime(), first.start.getTime()); + await act(() => ref.current.setView('week')); + assert.equal(ranges.length, 4, 'view changes are preserved'); + assert.equal(ranges[3].view, 'week'); + + await act(() => root.unmount()); + assert.equal(ref.current, null); + element.dispatchEvent(new CustomEvent('calendar-range-change', { detail: first })); + assert.equal(ranges.length, 4, 'listeners are detached on unmount'); +} finally { + if (ref.current) await act(() => root.unmount()); + // Detach intentionally preserves interface state for reattachment. Tests + // own the element and explicitly release its background calendar resources. + element?.destroy(); + container.remove(); + dom.window.close(); +} diff --git a/test/real-interface.mjs b/test/real-interface.mjs new file mode 100644 index 0000000..045605a --- /dev/null +++ b/test/real-interface.mjs @@ -0,0 +1,18 @@ +/** Verify the published interface bundle, not only the adapter's DOM stub. */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const fixture = fileURLToPath(new URL('./fixtures/real-interface.mjs', import.meta.url)); +for (const load of ['cold', 'warm']) { + for (const mode of ['normal', 'strict']) { + test(`real interface: ${load} definition, ${mode} React mount`, () => { + const result = spawnSync(process.execPath, [fixture, load, mode], { + encoding: 'utf8', timeout: 30_000, + }); + assert.equal(result.error, undefined, result.error?.message); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + }); + } +} diff --git a/test/types.tsx b/test/types.tsx index d8cd039..188018a 100644 --- a/test/types.tsx +++ b/test/types.tsx @@ -4,6 +4,8 @@ * line below it stops being an error. */ import { createRef, useRef } from 'react'; +// Loading both packages must not redeclare the global custom-element mapping. +import type {} from '@forcecalendar/interface'; import { ForceCalendar, type CalendarEvent, @@ -11,7 +13,7 @@ import { type ForceCalendarElement, type ForceCalendarHandle, type RangeChangeDetail, -} from '../src/index'; +} from '../dist/index.js'; export function Accepted() { const handle = useRef(null); @@ -71,3 +73,13 @@ export async function HandleTypes(handle: ForceCalendarHandle) { handle.setView('year'); return [range, deleted]; } + +// Adapter input types stay structural, including custom fields and ISO dates. +const plainEvent: CalendarEvent = { id: 'plain', start: '2026-10-02', owner: 'team' }; +export function StructuralInputs(handle: ForceCalendarHandle, element: ForceCalendarElement) { + handle.addEvent({ title: 'Draft', start: '2026-10-02' }); + handle.setEvents([plainEvent]); + element.setEvents([plainEvent]); + element.updateEvent('plain', { start: '2026-10-03', owner: 'another team' }); + return document.querySelector('forcecal-main'); +} diff --git a/tsconfig.test.json b/tsconfig.test.json index a1a5bfd..6478950 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -2,7 +2,8 @@ "extends": "./tsconfig.json", "compilerOptions": { "noEmit": true, - "declaration": false + "declaration": false, + "skipLibCheck": false }, "include": ["src", "test/types.tsx"] } diff --git a/tsconfig.test.nodenext.json b/tsconfig.test.nodenext.json new file mode 100644 index 0000000..0b85d33 --- /dev/null +++ b/tsconfig.test.nodenext.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.test.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext" + }, + "include": [ + "test/types.tsx" + ] +}