fix(react): allow key attribute on components created by with - #1981
fix(react): allow key attribute on components created by with#1981geongyu09 wants to merge 3 commits into
Conversation
People can be co-author:
|
|
@geongyu09 is attempting to deploy a commit to the Toss Team on Vercel. A member of the Team first needs to authorize it. |
🦋 Changeset detectedLatest commit: fa70671 The changes in this PR will be included in the next version bump. This PR includes changesets to release 6 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #1981 +/- ##
=======================================
Coverage 93.22% 93.22%
=======================================
Files 42 42
Lines 664 664
Branches 163 163
=======================================
Hits 619 619
Misses 42 42
Partials 3 3
🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
🟢 Approval recommended
The focused type fix is covered by regression tests, with no unresolved issues.
Pull request overview
Updates React with helpers so prop-less components accept the reserved key attribute while rejecting arbitrary props.
Changes:
- Updates default prop types across five helpers.
- Adds compile-time regression tests.
- Adds a patch changeset for
@suspensive/react.
File summaries
| File | Description |
|---|---|
packages/react/src/Suspense.tsx |
Updates default wrapped-component props. |
packages/react/src/Suspense.test-d.tsx |
Tests Suspense.with typing. |
packages/react/src/ErrorBoundaryGroup.tsx |
Updates default wrapped-component props. |
packages/react/src/ErrorBoundaryGroup.test-d.tsx |
Tests ErrorBoundaryGroup.with typing. |
packages/react/src/ErrorBoundary.tsx |
Updates default wrapped-component props. |
packages/react/src/ErrorBoundary.test-d.tsx |
Tests ErrorBoundary.with typing. |
packages/react/src/Delay.tsx |
Updates default wrapped-component props. |
packages/react/src/Delay.test-d.tsx |
Tests Delay.with typing. |
packages/react/src/ClientOnly.tsx |
Updates default wrapped-component props. |
packages/react/src/ClientOnly.test-d.tsx |
Tests ClientOnly.with typing. |
.changeset/fix-with-key-attribute.md |
Records the patch release. |
Review details
- Files reviewed: 11/11 changed files
- Comments generated: 0
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
manudeli
left a comment
There was a problem hiding this comment.
Good catch, thanks! 👀🦅
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
🟡 Changes recommended
The new default type permits arbitrary props through direct calls and structural assignments.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 11/11 changed files
- Comments generated: 5
- Review effort level: Balanced
| { | ||
| displayName: 'ClientOnly', | ||
| with: <TProps extends ComponentProps<ComponentType> = Record<string, never>>( | ||
| with: <TProps extends ComponentProps<ComponentType> = Record<never, never>>( |
| { | ||
| displayName: 'Delay', | ||
| with: <TProps extends ComponentProps<ComponentType> = Record<string, never>>( | ||
| with: <TProps extends ComponentProps<ComponentType> = Record<never, never>>( |
| displayName: 'ErrorBoundary', | ||
| with: < | ||
| TProps extends ComponentProps<ComponentType> = Record<string, never>, | ||
| TProps extends ComponentProps<ComponentType> = Record<never, never>, |
| { | ||
| displayName: 'ErrorBoundaryGroup', | ||
| with: <TProps extends ComponentProps<ComponentType> = Record<string, never>>( | ||
| with: <TProps extends ComponentProps<ComponentType> = Record<never, never>>( |
| { | ||
| displayName: 'Suspense', | ||
| with: <TProps extends ComponentProps<ComponentType> = Record<string, never>>( | ||
| with: <TProps extends ComponentProps<ComponentType> = Record<never, never>>( |
There was a problem hiding this comment.
@copilot Do we should refer https://github.com/bvaughn/react-error-boundary/blob/main/lib/utils/withErrorBoundary.ts ? cc. @geongyu09
There was a problem hiding this comment.
@manudeli Thanks for the reference link. I agree with your idea!
As Copilot pointed out, Record<never, never> collapses to {}, so while JSX still rejects <Wrapped foo="bar" />, both Wrapped({ foo: 'bar' }) and const props: ComponentProps<typeof Wrapped> = { foo: 'bar' } pass type checking.
The type tests I added only cover JSX, which is why I missed this.
Personally, I think following the withErrorBoundary approach is the better option, and not only because of the key issue.
While prototyping it on Suspense.with (forwardRef<ComponentRef<TComponent>, ComponentProps<TComponent>> + createElement(Component, { ...props, ref })), I found that the current implementation doesn't forward refs on React 18.
The types accept ref (since it's inferred from the wrapped component's props), but at runtime ref.current stays null and React logs the "Function components cannot be given refs" warning.
It only works on React 19 because ref is a regular prop there.
Verified with react-dom 18.3.1 and 19.2.6:
| Current implementation | forwardRef implementation | |
|---|---|---|
| React 18 | ref.current === null + warning |
Attached correctly |
| React 19 | Attached correctly | Attached correctly |
// Types pass, but on React 18 ref.current is null at runtime (with the "Function components cannot be given refs" warning)
import { createRef, forwardRef } from 'react'
const Inner = forwardRef<HTMLDivElement, { text: string }>(({ text }, ref) => <div ref={ref}>{text}</div>)
const WrappedInner = Suspense.with({ fallback: null }, Inner)
const ref = createRef<HTMLDivElement>()
const el = <WrappedInner ref={ref} text="t" /> // no type errorTo be fair, I'm not sure whether the missing ref support on React 18 should count as a bug, since I'm not deeply familiar with the library's design decisions.
But given that the library supports ^18 || ^19, it seems likely to confuse users.
Switching to the forwardRef version also resolves the key issue and makes the non-JSX paths strict as well.
So I think it's worth fixing.
That said, this change feels out of scope for the current PR.
Here's my proposal:
- Keep this PR minimal: change the default to
Attributesand add the non-JSX negative tests Copilot suggested. - In a follow-up PR, switch
withon all five components to a forwardRef-based implementation and add ref-forwarding tests for both React 18 and 19.
Would you be fine with this approach, or would you prefer doing everything in this PR? Happy to go either way!
Overview
Suspense.with,ErrorBoundary.with,ErrorBoundaryGroup.with,Delay.with, andClientOnly.withreject the React-reservedkeyattribute when the wrapped component takes no props.Cause
The
TPropstype parameter of everywithhelper defaults toRecord<string, never>. Its string index signature makes every attribute — includingkey— resolve tonever, so TypeScript rejectskeyeven though React always accepts it on any element.Fix
Change the default to
Record<never, never>.It still means "no props" — arbitrary props such as
foo="bar"remain a type error — but it has no index signature, so React's ownkeyhandling applies again. The same change is applied to all five components for consistency.Tests
Added type tests (
*.test-d.tsx) for each of the fivewithhelpers covering:keyis accepted when the wrapped component has no props@ts-expect-error)keyworks alongside inferred propsAlso added a
patchchangeset for@suspensive/react.PR Checklist