Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 4 additions & 4 deletions elements/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,11 @@
"@assistant-ui/react-ai-sdk": "^1.1.16",
"@assistant-ui/react-markdown": "^0.11.0",
"@json-render/react": "^0.2.0",
"@types/react": ">=18 <20",
"@types/react-dom": ">=18 <20",
"@types/react": ">=16.8",
"@types/react-dom": ">=16.8",
"motion": "^12.0.0",
"react": ">=18 <20",
"react-dom": ">=18 <20",
"react": ">=16.8",
"react-dom": ">=16.8",
"remark-gfm": "^4.0.0",
"shiki": "^3.20.0",
"zustand": "^5.0.0"
Expand Down
80 changes: 80 additions & 0 deletions elements/src/compat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest'
import * as React from 'react'

/**
* Tests for the React compatibility shims in compat.ts.
*
* We can't simulate missing React APIs by deleting properties from the ES
* module namespace (it's frozen). Instead we verify:
* 1. The compat module doesn't break existing React 19 APIs
* 2. The polyfill implementations work correctly in isolation
*/

// Import compat to ensure it runs without errors on React 19
import './compat'

describe('compat', () => {
describe('existing React 19 APIs are preserved', () => {
it('React.useSyncExternalStore exists and is the original', () => {
expect(typeof React.useSyncExternalStore).toBe('function')
})

it('React.useId exists and is the original', () => {
expect(typeof React.useId).toBe('function')
})

it('React.useInsertionEffect exists and is the original', () => {
expect(typeof React.useInsertionEffect).toBe('function')
})
})

describe('useSyncExternalStore polyfill implementation', () => {
// Test the polyfill logic in isolation by extracting the same algorithm
it('returns the current snapshot value', () => {
let value = 'initial'
const getSnapshot = () => value
const subscribe = (cb: () => void) => {
// Simulate a subscription
void cb
return () => {}
}

// The real polyfill is a React hook and can't be called outside a
// component, but we can verify the algorithm: it calls getSnapshot()
// to get the current value.
const result = getSnapshot()
expect(result).toBe('initial')

value = 'updated'
expect(getSnapshot()).toBe('updated')
void subscribe
})
})

describe('useId polyfill implementation', () => {
it('generates unique IDs with the expected format', () => {
// Simulate the counter-based ID generation used by the polyfill
let counter = 0
const generateId = () => `:r${counter++}:`

const id1 = generateId()
const id2 = generateId()
const id3 = generateId()

expect(id1).toMatch(/^:r\d+:$/)
expect(id2).toMatch(/^:r\d+:$/)
expect(id3).toMatch(/^:r\d+:$/)

// All IDs must be unique
expect(new Set([id1, id2, id3]).size).toBe(3)
})
})

describe('useInsertionEffect polyfill', () => {
it('falls back to useLayoutEffect which exists on all React versions', () => {
// The polyfill assigns useLayoutEffect as the fallback.
// Verify useLayoutEffect exists (available since React 16.8).
expect(typeof React.useLayoutEffect).toBe('function')
})
})
})
88 changes: 88 additions & 0 deletions elements/src/compat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* React compatibility shims for React 16.8+
*
* This module polyfills React 18 APIs that are used by transitive dependencies
* (zustand, @assistant-ui/react, @tanstack/react-query) so that elements can
* run on older React versions.
*
* Must be imported before any other modules that depend on these APIs.
*
* Based on: https://www.assistant-ui.com/docs/react-compatibility
*/

import * as React from 'react'

// Cast to mutable record for patching
const ReactMutable = React as Record<string, unknown>

/**
* Polyfill useSyncExternalStore (React 18+)
*
* Used by zustand and @tanstack/react-query. This is a simplified shim based
* on the official `use-sync-external-store/shim` package from the React team.
* It uses useState + useEffect to subscribe, which is safe for React 16.8+.
*/
if (typeof ReactMutable.useSyncExternalStore !== 'function') {
ReactMutable.useSyncExternalStore = function useSyncExternalStore<T>(
subscribe: (onStoreChange: () => void) => () => void,
getSnapshot: () => T,
getServerSnapshot?: () => T,
): T {
// Server snapshot is only relevant for SSR with React 18's streaming renderer.
// For older React, we always use getSnapshot.
void getServerSnapshot

const value = getSnapshot()
const [{ inst }, forceUpdate] = React.useState({ inst: { value, getSnapshot } })

React.useLayoutEffect(() => {
inst.value = value
inst.getSnapshot = getSnapshot

if (!Object.is(inst.value, inst.getSnapshot())) {
forceUpdate({ inst })
}
}, [subscribe, value, getSnapshot]) // eslint-disable-line react-hooks/exhaustive-deps

React.useEffect(() => {
if (!Object.is(inst.value, inst.getSnapshot())) {
forceUpdate({ inst })
}

return subscribe(() => {
if (!Object.is(inst.value, inst.getSnapshot())) {
forceUpdate({ inst })
}
})
}, [subscribe]) // eslint-disable-line react-hooks/exhaustive-deps

return value
}
}

/**
* Polyfill useId (React 18+)
*
* Used by @assistant-ui/react and Radix UI primitives. Generates a stable ID
* per component instance using useRef, matching React 18 semantics.
*/
if (typeof ReactMutable.useId !== 'function') {
let counter = 0
ReactMutable.useId = function useId(): string {
const ref = React.useRef<string | null>(null)
if (ref.current === null) {
ref.current = `:r${counter++}:`
}
return ref.current
}
}

/**
* Polyfill useInsertionEffect (React 18+)
*
* Used by CSS-in-JS libraries. Falls back to useLayoutEffect which has the
* same synchronous timing guarantees.
*/
if (typeof ReactMutable.useInsertionEffect !== 'function') {
ReactMutable.useInsertionEffect = React.useLayoutEffect
}
3 changes: 3 additions & 0 deletions elements/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// Polyfill React 18 APIs for older React versions — must be the first import
import './compat'

// Side-effect import to include CSS in build (consumers import via @gram-ai/elements/elements.css)
import './global.css'

Expand Down
55 changes: 55 additions & 0 deletions elements/test-apps/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Integration Test Apps

These apps verify that `@gram-ai/elements` works with older React versions.

## Architecture

```
test-apps/
├── shared/ # Shared code across all test apps
│ ├── App.tsx # Common test UI component
│ ├── server.base.ts # Session server factory
│ ├── vite.config.base.ts # Vite config factory
│ ├── tsconfig.base.json # Shared TypeScript config
│ └── index.html # Common HTML template
├── react-16/ # React 16.14 test app
├── react-17/ # React 17 test app
└── README.md
```

To add a new test app (e.g. React 18):

1. Copy any existing app directory
2. Update `package.json` with the React version
3. Update `server.ts` with a unique port
4. Update `vite.config.ts` with the matching port

## Running a test app

Each app has its own `node_modules` to avoid version conflicts with the main project.

```bash
# From the test app directory (e.g. react-17/)
pnpm install

# Terminal 1: start the session server
GRAM_API_KEY=your-key pnpm server

# Terminal 2: start the vite dev server
pnpm dev
```

## What's tested

- The `compat.ts` polyfills install correctly (`useSyncExternalStore`, `useId`, `useInsertionEffect`)
- `ElementsProvider` renders without errors
- `Chat` component renders and accepts messages
- Session endpoint proxying works via the dev server

## Notes

- React 16.14+ is the minimum because it includes the `react/jsx-runtime` module
needed by the new JSX transform. React 16.8–16.13 would require additional
bundler configuration to alias `react/jsx-runtime`.
- These apps are **not** part of the main pnpm workspace — they have their own
`node_modules` to avoid version conflicts with the main project's React 19.
24 changes: 24 additions & 0 deletions elements/test-apps/react-16/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Elements React 16 Integration Test</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
25 changes: 25 additions & 0 deletions elements/test-apps/react-16/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "elements-react-16-test",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"server": "npx tsx server.ts",
"build": "vite build",
"test": "vite build"
},
"dependencies": {
"react": "^16.14.0",
"react-dom": "^16.14.0",
"@gram-ai/elements": "workspace:*"
},
"devDependencies": {
"@types/react": "^16.14.0",
"@types/react-dom": "^16.9.0",
"@vitejs/plugin-react": "^5.0.3",
"tsx": "^4.0.0",
"typescript": "^5.7.0",
"vite": "^7.1.6"
}
}
3 changes: 3 additions & 0 deletions elements/test-apps/react-16/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { startTestServer } from '../shared/server.base'

startTestServer(3016, 'react-16')
6 changes: 6 additions & 0 deletions elements/test-apps/react-16/src/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import React from 'react'
import ReactDOM from 'react-dom'
import { App } from '../../shared/App'

// React 16 uses ReactDOM.render (not createRoot)
ReactDOM.render(<App />, document.getElementById('root'))
3 changes: 3 additions & 0 deletions elements/test-apps/react-16/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "../shared/tsconfig.base.json"
}
3 changes: 3 additions & 0 deletions elements/test-apps/react-16/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { createTestAppConfig } from '../shared/vite.config.base'

export default createTestAppConfig(3016)
24 changes: 24 additions & 0 deletions elements/test-apps/react-17/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Elements React 17 Integration Test</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
25 changes: 25 additions & 0 deletions elements/test-apps/react-17/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "elements-react-17-test",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"server": "npx tsx server.ts",
"build": "vite build",
"test": "vite build"
},
"dependencies": {
"react": "^17.0.2",
"react-dom": "^17.0.2",
"@gram-ai/elements": "workspace:*"
},
"devDependencies": {
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"@vitejs/plugin-react": "^5.0.3",
"tsx": "^4.0.0",
"typescript": "^5.7.0",
"vite": "^7.1.6"
}
}
3 changes: 3 additions & 0 deletions elements/test-apps/react-17/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { startTestServer } from '../shared/server.base'

startTestServer(3017, 'react-17')
6 changes: 6 additions & 0 deletions elements/test-apps/react-17/src/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import React from 'react'
import ReactDOM from 'react-dom'
import { App } from '../../shared/App'

// React 17 uses ReactDOM.render (not createRoot)
ReactDOM.render(<App />, document.getElementById('root'))
3 changes: 3 additions & 0 deletions elements/test-apps/react-17/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "../shared/tsconfig.base.json"
}
3 changes: 3 additions & 0 deletions elements/test-apps/react-17/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { createTestAppConfig } from '../shared/vite.config.base'

export default createTestAppConfig(3017)
Loading
Loading