diff --git a/apps/rich-text-versioning/AGENTS.md b/apps/rich-text-versioning/AGENTS.md index 5db4157c88..2099f2790b 100644 --- a/apps/rich-text-versioning/AGENTS.md +++ b/apps/rich-text-versioning/AGENTS.md @@ -28,6 +28,7 @@ Standard Vite app. ## Sharp Edges & Invariants +- **`@tanstack/react-query` major uniqueness:** `field-editor-reference` hard-deps RQ `^4` while `field-editor-rich-text@6` / `field-editor-shared@4` peer-accept `^4 || ^5`. A dual-major install tree lets Vite bundle the wrong single major and crashes embedded entry/asset cards (`reading 'sys'`). `test/reactQueryMajors.spec.ts` fails CI if more than one major is present under `node_modules`. Do not remove the direct `@tanstack/react-query` pin without re-checking the install tree. - **`@contentful/field-editor-rich-text`** is embedded to provide the actual editing experience — this is the standard Contentful rich-text editor. Do not replace it with a custom editor. - **Versions stored as JSON** in a separate Contentful JSON field (not in the rich-text field itself). The versioning field must exist on the content type alongside the rich-text field — document this requirement clearly to users. - **Snapshot schema**: each snapshot has `{ id, timestamp, label, value: Document }`. Changing this schema requires migrating all existing version data. diff --git a/apps/rich-text-versioning/test/reactQueryMajors.spec.ts b/apps/rich-text-versioning/test/reactQueryMajors.spec.ts new file mode 100644 index 0000000000..f6e8835f70 --- /dev/null +++ b/apps/rich-text-versioning/test/reactQueryMajors.spec.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { analyzeReactQueryMajors, findReactQueryPackages } from './reactQueryMajors'; + +const appRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +describe('analyzeReactQueryMajors', () => { + it('passes when there are no copies (package not in tree)', () => { + const result = analyzeReactQueryMajors([]); + expect(result.ok).toBe(true); + expect(result.majors).toEqual([]); + }); + + it('passes when every copy shares one major', () => { + const result = analyzeReactQueryMajors([ + { version: '4.44.0', path: '/a/node_modules/@tanstack/react-query' }, + { version: '4.36.1', path: '/a/node_modules/nested/@tanstack/react-query' }, + ]); + expect(result.ok).toBe(true); + expect(result.majors).toEqual(['4']); + }); + + it('fails when majors 4 and 5 both appear (RTV crash-class tree)', () => { + const result = analyzeReactQueryMajors([ + { version: '5.101.2', path: '/a/node_modules/@tanstack/react-query' }, + { + version: '4.44.0', + path: '/a/node_modules/@contentful/field-editor-reference/node_modules/@tanstack/react-query', + }, + ]); + expect(result.ok).toBe(false); + expect(result.majors).toEqual(['4', '5']); + expect(result.message).toMatch(/multiple majors/i); + }); +}); + +describe('findReactQueryPackages (install tree)', () => { + it('finds only one major under this app node_modules', () => { + const copies = findReactQueryPackages(path.join(appRoot, 'node_modules')); + const result = analyzeReactQueryMajors(copies); + + expect(copies.length).toBeGreaterThan(0); + expect(result.ok, result.message).toBe(true); + expect(result.majors).toHaveLength(1); + }); +}); diff --git a/apps/rich-text-versioning/test/reactQueryMajors.ts b/apps/rich-text-versioning/test/reactQueryMajors.ts new file mode 100644 index 0000000000..08d05f2c1f --- /dev/null +++ b/apps/rich-text-versioning/test/reactQueryMajors.ts @@ -0,0 +1,108 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export type ReactQueryCopy = { + version: string; + path: string; +}; + +export type ReactQueryMajorsResult = { + ok: boolean; + majors: string[]; + copies: ReactQueryCopy[]; + message: string; +}; + +export function analyzeReactQueryMajors(copies: ReactQueryCopy[]): ReactQueryMajorsResult { + const majors = [ + ...new Set(copies.map((copy) => copy.version.split('.')[0]).filter(Boolean)), + ].sort(); + + if (majors.length <= 1) { + return { + ok: true, + majors, + copies, + message: + majors.length === 0 + ? 'No @tanstack/react-query copies found.' + : `Single @tanstack/react-query major found: ${majors[0]}.`, + }; + } + + const listed = copies.map((copy) => ` ${copy.version} @ ${copy.path}`).join('\n'); + return { + ok: false, + majors, + copies, + message: [ + `Found multiple majors of @tanstack/react-query (${majors.join(', ')}).`, + 'Vite collapses these to a single major at bundle time; the wrong one', + 'breaks embedded entry/asset cards in field-editor-rich-text (sys crash).', + 'Ensure the install tree resolves to one major.', + listed, + ].join('\n'), + }; +} + +/** + * Walk node_modules for every physical @tanstack/react-query package.json. + * Nested installs under field-editor-reference are the interesting case. + */ +export function findReactQueryPackages(nodeModulesRoot: string): ReactQueryCopy[] { + const hits: ReactQueryCopy[] = []; + + function walk(dir: string, depth: number): void { + if (depth > 12) return; + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + // Skip Vite/Vitest caches and bin stubs + if (entry.name === '.bin' || entry.name === '.vite' || entry.name === '.cache') continue; + + const full = path.join(dir, entry.name); + + if (entry.name === '@tanstack') { + const pkgPath = path.join(full, 'react-query', 'package.json'); + if (fs.existsSync(pkgPath)) { + try { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { version?: string }; + if (pkg.version) { + hits.push({ version: pkg.version, path: path.dirname(pkgPath) }); + } + } catch { + // ignore unreadable package.json + } + } + continue; + } + + if ( + entry.name === 'node_modules' || + dir.endsWith(`${path.sep}node_modules`) || + entry.name.startsWith('@') + ) { + walk(full, depth + 1); + continue; + } + + const nested = path.join(full, 'node_modules'); + if (fs.existsSync(nested)) { + walk(nested, depth + 1); + } + } + } + + if (fs.existsSync(nodeModulesRoot)) { + walk(nodeModulesRoot, 0); + } + + return hits; +}