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

Merged
merged 16 commits into from
Jan 13, 2025
Merged
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
7 changes: 5 additions & 2 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ on:
branches: [master]

jobs:
build-test-lint:
build-lint-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
Expand All @@ -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
run: yarn test
28 changes: 18 additions & 10 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,23 @@ 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'
```tsx
import { useState, useEffect, act } from 'react'
import { render } from 'react-nil'

declare module 'react' {
namespace JSX {
interface IntrinsicElements {
timestamp: Record<string, unknown>
}
}
}

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

Expand Down
43 changes: 24 additions & 19 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "react-nil",
"version": "1.3.1",
"version": "1.3.0",
"description": "A react custom renderer that renders nothing but logical components",
"keywords": [
"react",
Expand All @@ -22,34 +22,39 @@
"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": "^22.10.6",
"@types/react": "^19.0.0",
"react": "^19.0.0",
"typescript": "^5.7.3",
"vite": "^6.0.7",
"vitest": "^2.1.8"
},
"dependencies": {
"@types/react-reconciler": "^0.26.7",
"react-reconciler": "^0.27.0"
"@types/react-reconciler": "^0.28.9",
"react-reconciler": "^0.31.0"
},
"peerDependencies": {
"react": "^18.0.0"
"react": "^19.0.0"
},
"scripts": {
"build": "rimraf dist && vite build && tsc",
"test": "vitest run"
"build": "vite build",
"test": "vitest run",
"lint": "tsc"
}
}
53 changes: 25 additions & 28 deletions src/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,20 @@
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 { it, expect } from 'vitest'
import { render, createPortal, type HostContainer } from './index'

// Let React know that we'll be testing effectful components
declare global {
var IS_REACT_ACT_ENVIRONMENT: boolean
}

// Let React know that we'll be testing effectful components
global.IS_REACT_ACT_ENVIRONMENT = true

// Mock scheduler to test React features
vi.mock('scheduler', () => require('scheduler/unstable_mock'))
globalThis.IS_REACT_ACT_ENVIRONMENT = true

interface ReactProps<T> {
key?: React.Key
ref?: React.Ref<T>
children?: React.ReactNode
}

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

function Test() {
lifecycle.push('render')
React.useImperativeHandle(React.useRef(), () => void lifecycle.push('ref'))
React.useImperativeHandle(React.useRef(undefined), () => void lifecycle.push('ref'))
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 +43,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 +69,22 @@ 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 />)))
const promise = Promise.resolve(null)
const Test = () => (React.use(promise), (<element bar />))
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 +93,30 @@ 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 />)))
const promise = Promise.resolve(null)
const Test = () => (React.use(promise), (<>three</>))
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