Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: React 19 #19

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
3 changes: 3 additions & 0 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,8 @@ jobs:
- name: Check build health
run: yarn build

- name: Check for regressions
run: yarn lint

- name: Run tests
run: yarn test --silent
16 changes: 8 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,12 @@ You can try a small demo here: https://codesandbox.io/s/react-nil-mvpry
The following renders a logical component without a view, it renders nothing, but it has a real lifecycle and is managed by React regardless.

```jsx
import * as React from 'react'
import { useState, useEffect } from 'react'
import { render } from 'react-nil'

function Foo() {
const [active, set] = React.useState(false)
React.useEffect(() => void setInterval(() => set((a) => !a), 1000), [])
const [active, set] = useState(false)
useEffect(() => void setInterval(() => set((a) => !a), 1000), [])

// false, true, ...
console.log(active)
Expand All @@ -40,15 +40,15 @@ render(<Foo />)

We can take this further by rendering made-up elements that get returned as a reactive JSON tree from `render`.

You can take a snapshot for testing via `act` which will wait for effects and suspense to finish.
You can take a snapshot for testing via `React.act` which will wait for effects and suspense to finish.

```jsx
import * as React from 'react'
import { act, render } from 'react-nil'
import { useState, useEffect, act } from 'react'
import { render } from 'react-nil'

function Test(props) {
const [value, setValue] = React.useState(-1)
React.useEffect(() => setValue(Date.now()), [])
const [value, setValue] = useState(-1)
useEffect(() => setValue(Date.now()), [])
return <timestamp value={value} />
}

Expand Down
49 changes: 31 additions & 18 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,34 +22,47 @@
"dist/*",
"src/*"
],
"type": "module",
"types": "./dist/index.d.ts",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"exports": {
"types": "./dist/index.d.ts",
"require": "./dist/index.js",
"import": "./dist/index.mjs"
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
},
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"sideEffects": false,
"devDependencies": {
"@types/node": "^18.7.14",
"@types/react": "^18.0.17",
"react": "^18.2.0",
"rimraf": "^3.0.2",
"suspend-react": "^0.0.8",
"typescript": "^4.7.4",
"vite": "^3.0.9",
"vitest": "^0.22.1"
"@types/node": "^20.12.12",
"@types/react": "npm:types-react@beta",
"@vitejs/plugin-react": "^4.2.1",
"react": "19.0.0-rc-915b914b3a-20240515",
"suspend-react": "^0.1.3",
"typescript": "^5.4.5",
"vite": "^5.2.11",
"vitest": "^1.6.0"
},
"dependencies": {
"@types/react-reconciler": "^0.26.7",
"react-reconciler": "^0.27.0"
"@types/react-reconciler": "^0.28.8",
"react-reconciler": "0.31.0-rc-915b914b3a-20240515"
},
"peerDependencies": {
"react": ">=18.0"
"react": ">=19.0"
},
"overrides": {
"@types/react": "npm:types-react@beta"
},
"resolutions": {
"@types/react": "npm:types-react@beta"
},
"scripts": {
"build": "rimraf dist && vite build && tsc",
"test": "vitest run"
"build": "vite build",
"test": "vitest run",
"lint": "tsc"
}
}
63 changes: 45 additions & 18 deletions src/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import * as React from 'react'
import { suspend } from 'suspend-react'
import { vi, it, expect } from 'vitest'
import { act, render, createPortal, type HostContainer } from './index'
import { render, createPortal, type HostContainer } from './index.js'
import type {} from 'react'
import type {} from 'react/jsx-runtime'
import type {} from 'react/jsx-dev-runtime'

// Elevate React warnings
console.warn = console.error = (message: string) => {
throw new Error(message)
}

declare global {
var IS_REACT_ACT_ENVIRONMENT: boolean
Expand All @@ -19,7 +27,23 @@ interface ReactProps<T> {
children?: React.ReactNode
}

declare global {
declare module 'react' {
namespace JSX {
interface IntrinsicElements {
element: ReactProps<null> & Record<string, unknown>
}
}
}

declare module 'react/jsx-runtime' {
namespace JSX {
interface IntrinsicElements {
element: ReactProps<null> & Record<string, unknown>
}
}
}

declare module 'react/jsx-dev-runtime' {
namespace JSX {
interface IntrinsicElements {
element: ReactProps<null> & Record<string, unknown>
Expand All @@ -32,13 +56,16 @@ it('should go through lifecycle', async () => {

function Test() {
lifecycle.push('render')
React.useImperativeHandle(React.useRef(), () => void lifecycle.push('ref'))
React.useImperativeHandle(React.useRef(null), () => {
lifecycle.push('ref')
return null
})
React.useInsertionEffect(() => void lifecycle.push('useInsertionEffect'), [])
React.useLayoutEffect(() => void lifecycle.push('useLayoutEffect'), [])
React.useEffect(() => void lifecycle.push('useEffect'), [])
return null
}
const container: HostContainer = await act(async () => render(<Test />))
const container: HostContainer = await React.act(async () => render(<Test />))

expect(lifecycle).toStrictEqual(['render', 'useInsertionEffect', 'ref', 'useLayoutEffect', 'useEffect'])
expect(container.head).toBe(null)
Expand All @@ -48,19 +75,19 @@ it('should render JSX', async () => {
let container!: HostContainer

// Mount
await act(async () => (container = render(<element key={1} foo />)))
await React.act(async () => (container = render(<element key={1} foo />)))
expect(container.head).toStrictEqual({ type: 'element', props: { foo: true }, children: [] })

// Remount
await act(async () => (container = render(<element bar />)))
await React.act(async () => (container = render(<element bar />)))
expect(container.head).toStrictEqual({ type: 'element', props: { bar: true }, children: [] })

// Mutate
await act(async () => (container = render(<element foo />)))
await React.act(async () => (container = render(<element foo />)))
expect(container.head).toStrictEqual({ type: 'element', props: { foo: true }, children: [] })

// Child mount
await act(async () => {
await React.act(async () => {
container = render(
<element foo>
<element />
Expand All @@ -74,21 +101,21 @@ it('should render JSX', async () => {
})

// Child unmount
await act(async () => (container = render(<element foo />)))
await React.act(async () => (container = render(<element foo />)))
expect(container.head).toStrictEqual({ type: 'element', props: { foo: true }, children: [] })

// Unmount
await act(async () => (container = render(<></>)))
await React.act(async () => (container = render(<></>)))
expect(container.head).toBe(null)

// Suspense
const Test = () => (suspend(async () => null, []), (<element bar />))
await act(async () => (container = render(<Test />)))
await React.act(async () => (container = render(<Test />)))
expect(container.head).toStrictEqual({ type: 'element', props: { bar: true }, children: [] })

// Portals
const portalContainer: HostContainer = { head: null }
await act(async () => (container = render(createPortal(<element />, portalContainer))))
await React.act(async () => (container = render(createPortal(<element />, portalContainer))))
expect(container.head).toBe(null)
expect(portalContainer.head).toStrictEqual({ type: 'element', props: {}, children: [] })
})
Expand All @@ -97,29 +124,29 @@ it('should render text', async () => {
let container!: HostContainer

// Mount
await act(async () => (container = render(<>one</>)))
await React.act(async () => (container = render(<>one</>)))
expect(container.head).toStrictEqual({ type: 'text', props: { value: 'one' }, children: [] })

// Remount
await act(async () => (container = render(<>one</>)))
await React.act(async () => (container = render(<>one</>)))
expect(container.head).toStrictEqual({ type: 'text', props: { value: 'one' }, children: [] })

// Mutate
await act(async () => (container = render(<>two</>)))
await React.act(async () => (container = render(<>two</>)))
expect(container.head).toStrictEqual({ type: 'text', props: { value: 'two' }, children: [] })

// Unmount
await act(async () => (container = render(<></>)))
await React.act(async () => (container = render(<></>)))
expect(container.head).toBe(null)

// Suspense
const Test = () => (suspend(async () => null, []), (<>three</>))
await act(async () => (container = render(<Test />)))
await React.act(async () => (container = render(<Test />)))
expect(container.head).toStrictEqual({ type: 'text', props: { value: 'three' }, children: [] })

// Portals
const portalContainer: HostContainer = { head: null }
await act(async () => (container = render(createPortal('four', portalContainer))))
await React.act(async () => (container = render(createPortal('four', portalContainer))))
expect(container.head).toBe(null)
expect(portalContainer.head).toStrictEqual({ type: 'text', props: { value: 'four' }, children: [] })
})
Loading
Loading