Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
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')
})
})
})
90 changes: 90 additions & 0 deletions elements/src/compat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* 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])

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

return subscribe(() => {
if (!Object.is(inst.value, inst.getSnapshot())) {
forceUpdate({ inst })
}
})
}, [subscribe])

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
4 changes: 2 additions & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading