diff --git a/.changeset/react-compat-plugin.md b/.changeset/react-compat-plugin.md new file mode 100644 index 000000000..0d6bf6749 --- /dev/null +++ b/.changeset/react-compat-plugin.md @@ -0,0 +1,5 @@ +--- +"@gram-ai/elements": minor +--- + +Add `reactCompat()` Vite plugin for React 16/17 support. Users on older React versions can add one line to their Vite config to polyfill React 18 APIs (`useSyncExternalStore`, `useId`, `useInsertionEffect`, `startTransition`, `useTransition`, `useDeferredValue`) used by Elements and its dependencies. diff --git a/elements/.gitignore b/elements/.gitignore index 0b1cf6011..c28e84854 100644 --- a/elements/.gitignore +++ b/elements/.gitignore @@ -18,4 +18,7 @@ storybook-static .DS_Store # Allow the bin directory -!bin \ No newline at end of file +!bin + +# Local test apps (not committed) +test-app-r16 \ No newline at end of file diff --git a/elements/README.md b/elements/README.md index 096444b18..0b8e1472a 100644 --- a/elements/README.md +++ b/elements/README.md @@ -324,6 +324,30 @@ function MarketingDemo() { | `assistantStartDelay` | `400` | Milliseconds before the assistant starts typing | | `onComplete` | — | Callback when replay finishes | +## React Compatibility + +`@gram-ai/elements` supports React 16.8+, React 17, React 18, and React 19. + +React 18 and 19 work out of the box. For React 16 or 17, you need to configure your bundler to shim newer React APIs (`useSyncExternalStore`, `useId`, `useInsertionEffect`) that are used by transitive dependencies like zustand and @assistant-ui/react. + +> **Note:** React 16 and React 17 are not regularly tested. If you run into any issues using Elements with these versions, please reach out to us for support. + +### Vite Setup (React 16/17) + +Add the compatibility plugin to your Vite config: + +```typescript +import react from '@vitejs/plugin-react' +import { reactCompat } from '@gram-ai/elements/compat' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [react(), reactCompat()], +}) +``` + +This automatically shims React 18 APIs (`useSyncExternalStore`, `useId`, `useInsertionEffect`) so that Elements and its dependencies work correctly on older React versions. + ## Contributing We welcome pull requests to Elements. Please read the contributing guide. diff --git a/elements/README.typedoc.md b/elements/README.typedoc.md index 553a8a45c..e45c3a6e2 100644 --- a/elements/README.typedoc.md +++ b/elements/README.typedoc.md @@ -19,6 +19,20 @@ const config: ElementsConfig = { The `mcp` and `projectSlug` values can be retrieved from the MCP and project pages respectively. +## React Compatibility + +`@gram-ai/elements` supports React 16.8+, React 17, React 18, and React 19. React 18 and 19 work out of the box. For React 16 or 17, add the compatibility plugin to your Vite config: + +```ts +import { reactCompat } from '@gram-ai/elements/compat' + +export default defineConfig({ + plugins: [react(), reactCompat()], +}) +``` + +React 16 and React 17 are not regularly tested — please reach out to us for support if you run into any issues with these versions. + ## API Documentation `ElementsConfig` is the top level configuration object for the Elements library. Please refer the [ElementsConfig](./docs/interfaces/ElementsConfig.md) interface documentation for more details on how to configure Elements. diff --git a/elements/package.json b/elements/package.json index ff9e2bd90..1cf0449d2 100644 --- a/elements/package.json +++ b/elements/package.json @@ -20,6 +20,11 @@ "import": "./dist/plugins.js", "require": "./dist/plugins.cjs" }, + "./compat": { + "types": "./dist/compat-plugin.d.ts", + "import": "./dist/compat-plugin.js", + "require": "./dist/compat-plugin.cjs" + }, "./elements.css": "./dist/elements.css" }, "bin": { @@ -63,11 +68,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" diff --git a/elements/src/compat-plugin.ts b/elements/src/compat-plugin.ts new file mode 100644 index 000000000..5df977914 --- /dev/null +++ b/elements/src/compat-plugin.ts @@ -0,0 +1,38 @@ +/** + * Vite plugin for React 16/17 compatibility. + * + * Usage: + * ```ts + * import { reactCompat } from '@gram-ai/elements/compat' + * export default defineConfig({ plugins: [reactCompat(), react()] }) + * ``` + */ + +import { createRequire } from 'node:module' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Plugin } from 'vite' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const shimPath = resolve(__dirname, 'react-shim.js') + +/** Redirects `import ... from 'react'` through a shim that polyfills React 18 APIs. */ +export function reactCompat(): Plugin { + return { + name: 'gram-elements-react-compat', + enforce: 'pre', + config() { + const require = createRequire(resolve(process.cwd(), 'package.json')) + const realReactPath = dirname(require.resolve('react/package.json')) + return { + resolve: { + alias: [ + { find: 'react-original', replacement: realReactPath }, + { find: /^react$/, replacement: shimPath }, + ], + dedupe: ['react', 'react-dom'], + }, + } + }, + } +} diff --git a/elements/src/compat-shims.ts b/elements/src/compat-shims.ts new file mode 100644 index 000000000..004cb077c --- /dev/null +++ b/elements/src/compat-shims.ts @@ -0,0 +1,75 @@ +/** + * Polyfill factories for React 18 APIs. Shared by compat.ts (runtime patching) + * and react-shim.ts (bundler-level replacement). This module must NOT import + * from 'react' to avoid circular dependencies when the Vite plugin is active. + */ + +interface ReactLike { + useState: typeof import('react').useState + useEffect: typeof import('react').useEffect + useLayoutEffect: typeof import('react').useLayoutEffect + useRef: typeof import('react').useRef + useSyncExternalStore?: typeof import('react').useSyncExternalStore + useId?: typeof import('react').useId + useInsertionEffect?: typeof import('react').useInsertionEffect + startTransition?: typeof import('react').startTransition + useTransition?: typeof import('react').useTransition + useDeferredValue?: typeof import('react').useDeferredValue +} + +function snapshotChanged(inst: { value: T; getSnapshot: () => T }): boolean { + try { + return !Object.is(inst.value, inst.getSnapshot()) + } catch { + return true + } +} + +function createUseSyncExternalStoreShim(R: ReactLike) { + return function useSyncExternalStore( + subscribe: (cb: () => void) => () => void, + getSnapshot: () => T + ): T { + const value = getSnapshot() + const [{ inst }, forceUpdate] = R.useState({ inst: { value, getSnapshot } }) + + R.useLayoutEffect(() => { + inst.value = value + inst.getSnapshot = getSnapshot + if (snapshotChanged(inst)) forceUpdate({ inst }) + }, [subscribe, value, getSnapshot]) + + R.useEffect(() => { + if (snapshotChanged(inst)) forceUpdate({ inst }) + return subscribe(() => { + if (snapshotChanged(inst)) forceUpdate({ inst }) + }) + }, [subscribe]) + + return value + } +} + +function createUseIdShim(R: ReactLike) { + let counter = 0 + return function useId(): string { + const ref = R.useRef(null) + if (ref.current === null) ref.current = `:r${counter++}:` + return ref.current + } +} + +/** Build polyfills for a React instance. Native APIs take precedence via ??. */ +export function createShims(R: ReactLike) { + return { + useSyncExternalStore: + R.useSyncExternalStore ?? createUseSyncExternalStoreShim(R), + useId: R.useId ?? createUseIdShim(R), + useInsertionEffect: R.useInsertionEffect ?? R.useLayoutEffect, + startTransition: R.startTransition ?? ((cb: () => void) => cb()), + useTransition: + R.useTransition ?? + ((): [boolean, (cb: () => void) => void] => [false, (cb) => cb()]), + useDeferredValue: R.useDeferredValue ?? ((value: T): T => value), + } +} diff --git a/elements/src/compat.test.ts b/elements/src/compat.test.ts new file mode 100644 index 000000000..3f1900e90 --- /dev/null +++ b/elements/src/compat.test.ts @@ -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') + }) + }) +}) diff --git a/elements/src/compat.ts b/elements/src/compat.ts new file mode 100644 index 000000000..45f4d1515 --- /dev/null +++ b/elements/src/compat.ts @@ -0,0 +1,19 @@ +/** + * Runtime React 16/17 compatibility shims. + * + * Patches the React module object with polyfills for React 18 APIs used by + * transitive deps (zustand, @assistant-ui/react, @tanstack/react-query). + * Must be imported before any modules that depend on these APIs. + */ + +import * as React from 'react' +import { createShims } from './compat-shims' + +const ReactMutable = React as Record +const shims = createShims(React) + +for (const [key, impl] of Object.entries(shims)) { + if (typeof ReactMutable[key] !== 'function') { + ReactMutable[key] = impl + } +} diff --git a/elements/src/index.ts b/elements/src/index.ts index 72317da89..2ecbf9ba0 100644 --- a/elements/src/index.ts +++ b/elements/src/index.ts @@ -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' diff --git a/elements/src/react-shim.ts b/elements/src/react-shim.ts new file mode 100644 index 000000000..ba9472928 --- /dev/null +++ b/elements/src/react-shim.ts @@ -0,0 +1,54 @@ +/** + * Bundler-level React shim for React 16/17. The reactCompat() Vite plugin + * aliases 'react' to this file so named imports get polyfilled APIs. + * NOT meant to be imported directly. + */ + +// @ts-expect-error — resolved by the Vite plugin to the real react package +import * as ReactOriginal from 'react-original' +import { createShims } from './compat-shims' + +const Shimmed = { ...ReactOriginal, ...createShims(ReactOriginal) } + +// React internals required by react-dom +export const __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (ReactOriginal as any).__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED + +export const { + Children, + Component, + Fragment, + Profiler, + PureComponent, + StrictMode, + Suspense, + cloneElement, + createContext, + createElement, + createFactory, + createRef, + forwardRef, + isValidElement, + lazy, + memo, + startTransition, + useCallback, + useContext, + useDebugValue, + useDeferredValue, + useEffect, + useId, + useImperativeHandle, + useInsertionEffect, + useLayoutEffect, + useMemo, + useReducer, + useRef, + useState, + useSyncExternalStore, + useTransition, + version, +} = Shimmed + +export default Shimmed diff --git a/elements/vite.config.ts b/elements/vite.config.ts index c9d7e40dc..37df139b1 100644 --- a/elements/vite.config.ts +++ b/elements/vite.config.ts @@ -17,11 +17,12 @@ export default defineConfig({ // Automatically keep peerDependencies as they are defined in the package.json in sync // with the rollupOptions.external list externalizeDeps({ - // We mark deps as false because the plugins default behaviour is externalise all deps. deps: false, peerDeps: true, optionalDeps: false, devDeps: false, + // react-original is a virtual alias resolved by reactCompat() at consumer build time + include: ['react-original'], }), ], build: { @@ -32,6 +33,8 @@ export default defineConfig({ elements: resolve(__dirname, 'src/index.ts'), server: resolve(__dirname, 'src/server.ts'), plugins: resolve(__dirname, 'src/plugins/index.ts'), + 'compat-plugin': resolve(__dirname, 'src/compat-plugin.ts'), + 'react-shim': resolve(__dirname, 'src/react-shim.ts'), }, formats: ['es', 'cjs'], }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48f2e5865..f108067de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -456,10 +456,10 @@ importers: specifier: ^5.90.10 version: 5.90.10(react@19.2.3) '@types/react': - specifier: '>=18 <20' + specifier: '>=16.8' version: 19.2.7 '@types/react-dom': - specifier: '>=18 <20' + specifier: '>=16.8' version: 19.2.3(@types/react@19.2.7) ai: specifier: 5.0.90