Skip to content

fix(react): allow key attribute on components created by with - #1981

Open
geongyu09 wants to merge 3 commits into
toss:mainfrom
geongyu09:fix/with-key-attribute
Open

fix(react): allow key attribute on components created by with#1981
geongyu09 wants to merge 3 commits into
toss:mainfrom
geongyu09:fix/with-key-attribute

Conversation

@geongyu09

Copy link
Copy Markdown

Overview

Suspense.with, ErrorBoundary.with, ErrorBoundaryGroup.with, Delay.with, and ClientOnly.with reject the React-reserved key attribute when the wrapped component takes no props.

const Wrapped = Suspense.with({ fallback: null }, () => <></>)

// ❌ Type 'string' is not assignable to type 'never'.
const list = [<Wrapped key="a" />, <Wrapped key="b" />]

Cause

The TProps type parameter of every with helper defaults to Record<string, never>. Its string index signature makes every attribute — including key — resolve to never, so TypeScript rejects key even 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 own key handling applies again. The same change is applied to all five components for consistency.

- with: <TProps extends ComponentProps<ComponentType> = Record<string, never>>(
+ with: <TProps extends ComponentProps<ComponentType> = Record<never, never>>(

Tests

Added type tests (*.test-d.tsx) for each of the five with helpers covering:

  • key is accepted when the wrapped component has no props
  • arbitrary props are still rejected (@ts-expect-error)
  • key works alongside inferred props

Also added a patch changeset for @suspensive/react.

PR Checklist

  • I did below actions if need
  1. I read the Contributing Guide
  2. I added documents and tests.

@coauthors

coauthors Bot commented Aug 24, 2026

Copy link
Copy Markdown

People can be co-author:

Candidate Reasons Count Add this as commit message
@Copilot #1981 (comment) #1981 (comment) #1981 (comment) #1981 (comment) #1981 (comment) 5 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@geongyu09 #1981 (comment) #1981 (review) #1981 3 Co-authored-by: geongyu09 <67491015+geongyu09@users.noreply.github.com>
@manudeli #1981 (comment) #1981 (review) #1981 (review) 3 Co-authored-by: manudeli <61593290+manudeli@users.noreply.github.com>
@codecov-commenter #1981 (comment) 1 Co-authored-by: codecov-commenter <65553080+codecov-commenter@users.noreply.github.com>

@vercel

vercel Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

@geongyu09 is attempting to deploy a commit to the Toss Team on Vercel.

A member of the Team first needs to authorize it.

@changeset-bot

changeset-bot Bot commented Aug 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: fa70671

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@suspensive/react Patch
@suspensive/codemods Patch
@suspensive/jotai Patch
@suspensive/react-query-4 Patch
@suspensive/react-query-5 Patch
@suspensive/react-query Patch

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-commenter

codecov-commenter commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.22%. Comparing base (d86e08a) to head (fa70671).

Additional details and impacted files

Impacted file tree graph

@@           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           
Components Coverage Δ
@suspensive/react 100.00% <ø> (ø)
@suspensive/react-query 95.83% <ø> (ø)
@suspensive/react-query-4 100.00% <ø> (ø)
@suspensive/react-query-5 100.00% <ø> (ø)
@suspensive/jotai 100.00% <ø> (ø)
@suspensive/codemods 81.60% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟢 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
manudeli requested review from gwansikk and a balanced review from Copilot September 8, 2026 07:40

@manudeli manudeli left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Good catch, thanks! 👀🦅

@vercel

vercel Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
suspensive-next-streaming-react-query Ready Ready Preview Sep 8, 2026 7:45am UTC
v2.suspensive.org Ready Ready Preview Sep 8, 2026 7:45am UTC
v3.suspensive.org Ready Ready Preview Sep 8, 2026 7:45am UTC
visualization.suspensive.org Ready Ready Preview Sep 8, 2026 7:45am UTC

Request Review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 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>>(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

@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 error

To 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:

  1. Keep this PR minimal: change the default to Attributes and add the non-JSX negative tests Copilot suggested.
  2. In a follow-up PR, switch with on 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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants