Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .github/workflows/react-compat.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: React compat matrix
on:
pull_request:
branches:
- "**"
concurrency:
group: react-compat-${{ github.head_ref }}
cancel-in-progress: true
jobs:
compat:
runs-on: ubuntu-latest
strategy:
fail-fast: true
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: fail-fast: true means that if the React 16 cell fails, the React 17/18/19 cells are cancelled immediately. This is efficient for the CDX-458 use case (fail fast on the oldest version), but it makes it harder to see which versions are broken when multiple cells fail simultaneously (e.g. after a dependency upgrade). Consider setting fail-fast: false so all four cells always run to completion and the full compatibility picture is visible in a single CI run.

matrix:
react-major: ["16", "17", "18", "19"]
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "22.18.0"
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.
- name: Install library deps
run: npm ci
- name: Build library
run: npm run compile
- name: Pack library
shell: bash
run: |
npm pack --pack-destination .
tarballs=(constructor-io-constructorio-ui-components-*.tgz)
if [ ${#tarballs[@]} -ne 1 ]; then
echo "Expected exactly 1 tarball, found ${#tarballs[@]}: ${tarballs[*]}" >&2
exit 1
fi
mv "${tarballs[0]}" constructorio-ui-components.tgz
- name: Install fixture deps
Comment thread
esezen marked this conversation as resolved.
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.
working-directory: test/react-compat/${{ matrix.react-major }}
run: npm ci --no-audit --no-fund
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.
- name: Typecheck fixture
working-directory: test/react-compat/${{ matrix.react-major }}
run: npm run typecheck
- name: Webpack build
working-directory: test/react-compat/${{ matrix.react-major }}
run: npm run build
- name: Run fixture tests
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.
working-directory: test/react-compat/${{ matrix.react-major }}
run: npm test
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,9 @@ storybook-static

# Dist
lib/

# react-compat fixture artifacts
test/react-compat/*/node_modules/
test/react-compat/*/dist/
constructor-io-constructorio-ui-components-*.tgz
constructorio-ui-components.tgz
6 changes: 6 additions & 0 deletions Contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,9 @@
- YourNewComponent.css - Additional CSS styles, if any (Note: Use Tailwind whenever possible!)
- ... any other component-specific files
```

## React 16/17 compatibility

This library declares `peerDependencies.react: ">=16.12.0"` and is verified against React 16, 17, 18, and 19 by the matrix workflow at `.github/workflows/react-compat.yml`. Each cell builds a fixture in webpack 5 ESM-output mode (`experiments.outputModule: true`), which uses strict bare-specifier resolution — the same condition that surfaces the original CDX-458 failure.

When adding a runtime dependency, check that its published ESM build does not import `react/jsx-runtime` directly. React 16 and 17 ship `jsx-runtime.js` but have no `package.json` `exports` map, so strict-ESM bundlers reject the bare specifier and consumer builds break. The React 16 and 17 matrix cells will fail with `Module not found: Error: Can't resolve 'react/jsx-runtime'` if this gets reintroduced. The fastest way to test a candidate dep locally is `npm run build` inside `test/react-compat/16/` after installing it.
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,6 @@
"npm": "11.5.2"
},
"dependencies": {
"@radix-ui/react-slot": "^1.2.3",
"@tailwindcss/vite": "^4.1.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
Expand Down
20 changes: 20 additions & 0 deletions spec/components/Badge/Badge.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,26 @@ describe('Badge component', () => {
expect(renderedBadge.classList.contains('cio-badge')).toBeTruthy();
});

test('renders the child without any wrapping <span>', () => {
const { container } = render(
<Badge asChild variant='outline'>
<a href='/x'>linked badge</a>
</Badge>,
);
expect(container.querySelector('span')).toBeNull();
expect(container.querySelector('a')).not.toBeNull();
});

test('sets data-slot="badge" on the rendered child', () => {
render(
<Badge asChild>
<a href='/x'>linked badge</a>
</Badge>,
);
const a = screen.getByText('linked badge');
expect(a.getAttribute('data-slot')).toBe('badge');
});

test('renders componentOverride if passed', () => {
render(
<Badge
Expand Down
31 changes: 31 additions & 0 deletions spec/components/Button/Button.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,37 @@ describe('Button component', () => {
expect(renderedButton.classList.contains('cio-button')).toBeTruthy();
});

test('renders the child without any wrapping <button>', () => {
const { container } = render(
<Button asChild>
<a href='/x'>linked button</a>
</Button>,
);
expect(container.querySelector('button')).toBeNull();
expect(container.querySelector('a')).not.toBeNull();
});

test('sets data-slot="button" on the rendered child', () => {
render(
<Button asChild>
<a href='/x'>linked button</a>
</Button>,
);
const a = screen.getByText('linked button');
expect(a.getAttribute('data-slot')).toBe('button');
});

test('composes onClick from Button and the child (child first, then Button)', () => {
const order: string[] = [];
render(
<Button asChild onClick={() => order.push('button')}>
<a href='/x' onClick={() => order.push('child')}>linked button</a>
</Button>,
);
fireEvent.click(screen.getByText('linked button'));
expect(order).toEqual(['child', 'button']);
});

test('renders componentOverride if passed', () => {
render(
<Button
Expand Down
182 changes: 182 additions & 0 deletions spec/utils/slot.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import React from 'react';
import { render, cleanup, fireEvent } from '@testing-library/react';
import { describe, test, expect, vi, afterEach } from 'vitest';
import { Slot } from '@/utils';

describe('Slot', () => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});

// 1. Renders the single child element with no wrapper
test('renders the single child element with no extra wrapper DOM', () => {
const { container } = render(
<Slot>
<a href='/'>link</a>
</Slot>,
);
expect(container.firstChild).not.toBeNull();
expect((container.firstChild as HTMLElement).tagName).toBe('A');
expect(container.firstChild?.parentElement).toBe(container);
});

// 2. Forwards an object ref to the child DOM node
test('forwards an object ref to the child DOM node', () => {
const ref = React.createRef<HTMLAnchorElement>();
render(
<Slot ref={ref as React.Ref<HTMLElement>}>
<a href='/'>link</a>
</Slot>,
);
expect(ref.current).toBeInstanceOf(HTMLAnchorElement);
});

// 3. Forwards a callback ref to the child DOM node
test('forwards a callback ref to the child DOM node', () => {
let received: HTMLElement | null = null;
render(
<Slot ref={(node) => { received = node; }}>
<a href='/'>link</a>
</Slot>,
);
expect(received).toBeInstanceOf(HTMLAnchorElement);
});

// 4. Composes slot's ref with child element's own ref
test('composes slot ref with child own ref so both resolve to the same DOM node', () => {
const slotRef = React.createRef<HTMLAnchorElement>();
const childRef = React.createRef<HTMLAnchorElement>();
render(
<Slot ref={slotRef as React.Ref<HTMLElement>}>
<a href='/' ref={childRef}>
link
</a>
</Slot>,
);
expect(slotRef.current).toBeInstanceOf(HTMLAnchorElement);
expect(childRef.current).toBeInstanceOf(HTMLAnchorElement);
expect(slotRef.current).toBe(childRef.current);
});

// 5. Concatenates className from slot and child
test('concatenates className from slot and child', () => {
const { container } = render(
<Slot className='slot-class'>
<a href='/' className='child-class'>
link
</a>
</Slot>,
);
const el = container.firstChild as HTMLElement;
expect(el.className).toContain('slot-class');
expect(el.className).toContain('child-class');
});

// 6. Shallow-merges style; child overrides slot on conflict
test('shallow-merges style with child winning on conflict', () => {
const { container } = render(
<Slot style={{ color: 'red', margin: '4px' }}>
<a href='/' style={{ color: 'blue', padding: '2px' }}>
link
</a>
</Slot>,
);
const el = container.firstChild as HTMLElement;
expect(el.style.color).toBe('blue');
expect(el.style.margin).toBe('4px');
expect(el.style.padding).toBe('2px');
});

// 7. Composes event handlers: child runs first, then slot
test('composes event handlers: child handler runs before slot handler', () => {
const order: string[] = [];
const { container } = render(
<Slot onClick={() => { order.push('slot'); }}>
<button onClick={() => { order.push('child'); }} type='button'>
click me
</button>
</Slot>,
);
const button = container.firstChild as HTMLButtonElement;
fireEvent.click(button);
expect(order).toEqual(['child', 'slot']);
});

// 7b. Slot handler is skipped when child handler calls preventDefault
test('slot handler is skipped when child handler calls preventDefault', () => {
const slotClick = vi.fn();
const { container } = render(
<Slot onClick={slotClick}>
<button onClick={(e) => e.preventDefault()} type='button'>
click me
</button>
</Slot>,
);
const button = container.firstChild as HTMLButtonElement;
fireEvent.click(button);
expect(slotClick).not.toHaveBeenCalled();
});

// 8. Slot's handler fires when child has no handler of the same name
test("slot's handler fires when child has no handler for that event", () => {
const slotClick = vi.fn();
const { container } = render(
<Slot onClick={slotClick}>
<button type='button'>click me</button>
</Slot>,
);
const button = container.firstChild as HTMLButtonElement;
fireEvent.click(button);
expect(slotClick).toHaveBeenCalledTimes(1);
});

// 9. Child non-handler prop wins over slot's
test('child non-handler props win over slot props', () => {
const { container } = render(
<Slot id='slot-id' aria-label='slot label'>
<a href='/' id='child-id' aria-label='child label'>
link
</a>
</Slot>,
);
const el = container.firstChild as HTMLElement;
expect(el.id).toBe('child-id');
expect(el.getAttribute('aria-label')).toBe('child label');
});

// 10. Throws when given multiple children
test('throws when given multiple children', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() =>
render(
<Slot>
<span>one</span>
<span>two</span>
</Slot>,
),
).toThrow(/React.Children.only/);
});

// 11. Returns null when given no valid React element child
test('returns null when given a non-element child (plain string)', () => {
const { container } = render(<Slot>{'plain string'}</Slot>);
expect(container.firstChild).toBeNull();
});

// 12. Forwards ref correctly when child is a forwardRef component
test('forwards ref when child is a forwardRef component', () => {
const Inner = React.forwardRef<HTMLAnchorElement, React.ComponentProps<'a'>>((props, ref) => (
<a ref={ref} {...props} />
));
Inner.displayName = 'Inner';

const slotRef = React.createRef<HTMLAnchorElement>();
render(
<Slot ref={slotRef as React.Ref<HTMLElement>}>
<Inner href='/x'>link</Inner>
</Slot>,
);
expect(slotRef.current).toBeInstanceOf(HTMLAnchorElement);
});
});
3 changes: 1 addition & 2 deletions src/components/badge.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import React, { ReactNode } from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cn, RenderPropsWrapper } from '@/utils';
import { cn, RenderPropsWrapper, Slot } from '@/utils';
import { ComponentOverrideProps, IncludeComponentOverrides } from '@/types';
import { cva, VariantProps } from 'class-variance-authority';

Expand Down
3 changes: 1 addition & 2 deletions src/components/button.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import React, { ReactNode } from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cn, RenderPropsWrapper } from '@/utils';
import { cn, RenderPropsWrapper, Slot } from '@/utils';
import { ComponentOverrideProps, IncludeComponentOverrides } from '@/types';
import { cva, VariantProps } from 'class-variance-authority';

Expand Down
1 change: 1 addition & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
export * from './styleHelpers';
export * from './events';
export { default as RenderPropsWrapper } from './RenderPropsWrapper';
export { Slot } from './slot';
Loading
Loading