Skip to content
Open
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
1 change: 1 addition & 0 deletions apps/rich-text-versioning/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
47 changes: 47 additions & 0 deletions apps/rich-text-versioning/test/reactQueryMajors.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
108 changes: 108 additions & 0 deletions apps/rich-text-versioning/test/reactQueryMajors.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading