diff --git a/packages/components/docs/framework-id-generation.md b/packages/components/docs/framework-id-generation.md new file mode 100644 index 000000000000..711c9a61270e --- /dev/null +++ b/packages/components/docs/framework-id-generation.md @@ -0,0 +1,84 @@ +# Framework-Specific ID Generation + +This document explains how component ID generation works across different frameworks for SSR compatibility. + +## Overview + +Components in this design system that need to generate unique IDs (such as form components) now use framework-specific approaches to ensure SSR (Server-Side Rendering) compatibility. + +## Implementation + +### Before +All frameworks used a custom `uuid()` function which could cause hydration mismatches in SSR scenarios: +```javascript +const id = `component-${uuid()}`; +``` + +### After +Framework-specific ID generation is implemented through a post-build script: + +#### React Components +- Use React's `useId()` hook +- Import: `import { useId } from "react"` +- Usage: `const id = \`component-\${useId()}\`` + +#### Vue Components +- Use Vue's `useId()` hook +- Import: `import { useId } from "vue"` +- Usage: `const id = \`component-\${useId()}\`` + +#### Angular & Stencil Components +- Continue using `uuid()` function (fallback) +- Usage: `const id = \`component-\${uuid()}\`` + +## Benefits + +1. **SSR Compatibility**: React and Vue components now generate consistent IDs between server and client +2. **Hydration Safety**: Eliminates hydration mismatches in SSR applications +3. **Framework-Appropriate**: Uses each framework's recommended approach for ID generation +4. **Backward Compatibility**: Frameworks without native `useId()` support continue to work + +## Affected Components + +The following components have been updated to use framework-specific ID generation: +- textarea +- switch +- select +- radio +- input +- custom-select-list-item +- custom-select +- checkbox + +## Technical Details + +### Post-Build Processing +A post-build script (`scripts/post-build/use-id.ts`) automatically: +1. Scans generated React and Vue components +2. Adds appropriate `useId` imports +3. Replaces `uuid()` calls with `useId()` calls in ID generation patterns +4. Leaves Angular and Stencil components unchanged + +### Pattern Recognition +The script identifies ID generation patterns like: +```javascript +`component-${uuid()}` +``` + +And transforms them to: +```javascript +`component-${useId()}` // React/Vue only +``` + +## Testing + +Run the verification script to ensure proper integration: +```bash +npx tsx scripts/verify-use-id.ts +``` + +This verifies: +- Correct `useId` usage in React/Vue components +- Proper import statements +- No remaining `uuid()` patterns in ID generation +- Angular/Stencil components still use `uuid()` \ No newline at end of file diff --git a/packages/components/scripts/post-build/index.ts b/packages/components/scripts/post-build/index.ts index 64cc284ca767..08007102538a 100644 --- a/packages/components/scripts/post-build/index.ts +++ b/packages/components/scripts/post-build/index.ts @@ -3,9 +3,11 @@ import CopyFiles from './copy-files'; import React from './react'; import Stencil from './stencil'; import Vue from './vue'; +import UseId from './use-id'; CopyFiles(); Vue(); Stencil(); Angular(); React(); +UseId(); diff --git a/packages/components/scripts/post-build/use-id.ts b/packages/components/scripts/post-build/use-id.ts new file mode 100644 index 000000000000..c3abd88db1e4 --- /dev/null +++ b/packages/components/scripts/post-build/use-id.ts @@ -0,0 +1,102 @@ +import { readFileSync, writeFileSync } from 'fs'; +import { globSync } from 'glob'; +import path from 'path'; + +/** + * Post-build script to replace uuid() calls with framework-specific useId() hooks + * for SSR compatibility in React and Vue components. + */ + +const processReactFiles = () => { + const files = globSync('../../output/react/src/**/*.tsx'); + console.log(`Found ${files.length} React files to process`); + + files.forEach(filePath => { + let content = readFileSync(filePath, 'utf8'); + let modified = false; + + // Check if file uses uuid for ID generation + if (content.includes('uuid()') && content.includes('-${uuid()}')) { + console.log(`Processing file: ${filePath}`); + + // Add useId import if not already present + if (!content.includes('useId') && !content.includes('import { useId }')) { + console.log('Adding useId import'); + content = content.replace( + /import \* as React from "react";/, + 'import * as React from "react";\nimport { useId } from "react";' + ); + modified = true; + } + + // Replace uuid() calls with useId() in ID generation patterns + const beforeReplace = content; + content = content.replace( + /`([^`]*)-\$\{uuid\(\)\}`/g, + '`$1-${useId()}`' + ); + if (content !== beforeReplace) { + console.log('Replaced uuid() with useId()'); + modified = true; + } + } + + if (modified) { + writeFileSync(filePath, content); + console.log(`Updated React file: ${path.relative(process.cwd(), filePath)}`); + } + }); +}; + +const processVueFiles = () => { + const files = globSync('../../output/vue/src/**/*.vue'); + + files.forEach(filePath => { + let content = readFileSync(filePath, 'utf8'); + let modified = false; + + // Check if file uses uuid for ID generation + if (content.includes('uuid()') && content.includes('-${uuid()}')) { + // Add useId import if not already present + if (!content.includes('useId')) { + // Find existing imports and add useId + content = content.replace( + /(import.*from.*vue.*)/, + '$1\nimport { useId } from "vue";' + ); + modified = true; + } + + // Replace uuid() calls with useId() in ID generation patterns + content = content.replace( + /`([^`]*)-\$\{uuid\(\)\}`/g, + '`$1-${useId()}`' + ); + modified = true; + } + + if (modified) { + writeFileSync(filePath, content); + console.log(`Updated Vue file: ${path.relative(process.cwd(), filePath)}`); + } + }); +}; + +export default function UseId() { + // Run the post-processing + console.log('๐ Processing React files for useId integration...'); + try { + processReactFiles(); + } catch (error) { + console.error('Error processing React files:', error); + } + + console.log('๐ Processing Vue files for useId integration...'); + try { + processVueFiles(); + } catch (error) { + console.error('Error processing Vue files:', error); + } + + console.log('โ Post-build useId processing completed!'); +} \ No newline at end of file diff --git a/packages/components/scripts/verify-use-id.ts b/packages/components/scripts/verify-use-id.ts new file mode 100644 index 000000000000..1921426da6b0 --- /dev/null +++ b/packages/components/scripts/verify-use-id.ts @@ -0,0 +1,103 @@ +#!/usr/bin/env node + +/** + * Simple verification script to ensure useId integration is working correctly + */ + +import { readFileSync } from 'fs'; +import { globSync } from 'glob'; + +console.log('๐งช Verifying useId integration...\n'); + +// Test 1: Verify React components have useId +console.log('๐ React Components:'); +const reactFiles = globSync('../../output/react/src/components/**/*.tsx'); +let reactProcessed = 0; + +reactFiles.forEach(file => { + const content = readFileSync(file, 'utf8'); + if (content.includes('-${useId()}')) { + const componentName = file.split('/').pop()?.replace('.tsx', ''); + console.log(` โ ${componentName} - uses useId()`); + reactProcessed++; + } +}); + +console.log(` ๐ Total React components using useId: ${reactProcessed}\n`); + +// Test 2: Verify Vue components have useId +console.log('๐ Vue Components:'); +const vueFiles = globSync('../../output/vue/src/components/**/*.vue'); +let vueProcessed = 0; + +vueFiles.forEach(file => { + const content = readFileSync(file, 'utf8'); + if (content.includes('-${useId()}')) { + const componentName = file.split('/').pop()?.replace('.vue', ''); + console.log(` โ ${componentName} - uses useId()`); + vueProcessed++; + } +}); + +console.log(` ๐ Total Vue components using useId: ${vueProcessed}\n`); + +// Test 3: Verify no React/Vue components still use uuid for ID generation +console.log('๐ Checking for remaining uuid usage in ID patterns...'); +let issuesFound = 0; + +[...reactFiles, ...vueFiles].forEach(file => { + const content = readFileSync(file, 'utf8'); + const hasUuidIdPattern = content.match(/`[^`]*-\${uuid\(\)}`/); + + if (hasUuidIdPattern) { + console.log(` โ ๏ธ ${file} still uses uuid() for ID generation`); + issuesFound++; + } +}); + +if (issuesFound === 0) { + console.log(' โ No uuid-based ID patterns found in React/Vue components\n'); +} else { + console.log(` โ Found ${issuesFound} components still using uuid for IDs\n`); +} + +// Test 4: Verify imports are correctly added +console.log('๐ Checking import statements...'); +let importIssues = 0; + +reactFiles.forEach(file => { + const content = readFileSync(file, 'utf8'); + if (content.includes('useId()') && !content.includes('import { useId } from "react"')) { + console.log(` โ ๏ธ ${file} uses useId() but missing import`); + importIssues++; + } +}); + +vueFiles.forEach(file => { + const content = readFileSync(file, 'utf8'); + if (content.includes('useId()') && !content.includes('import { useId } from "vue"')) { + console.log(` โ ๏ธ ${file} uses useId() but missing import`); + importIssues++; + } +}); + +if (importIssues === 0) { + console.log(' โ All useId imports are correctly added\n'); +} else { + console.log(` โ Found ${importIssues} missing import statements\n`); +} + +// Summary +console.log('๐ Summary:'); +console.log(`โ React components processed: ${reactProcessed}`); +console.log(`โ Vue components processed: ${vueProcessed}`); +console.log(`โ Components with SSR-compatible IDs: ${reactProcessed + vueProcessed}`); +console.log(`โ Import issues: ${importIssues}`); +console.log(`โ UUID pattern issues: ${issuesFound}`); + +if (reactProcessed > 0 && vueProcessed > 0 && issuesFound === 0 && importIssues === 0) { + console.log('\n๐ All tests passed! SSR-compatible ID generation is working correctly.'); +} else { + console.log('\nโ Some issues found. Please review the output above.'); + process.exit(1); +} \ No newline at end of file diff --git a/packages/components/src/utils/framework-id.spec.ts b/packages/components/src/utils/framework-id.spec.ts new file mode 100644 index 000000000000..a5d038bc7fc8 --- /dev/null +++ b/packages/components/src/utils/framework-id.spec.ts @@ -0,0 +1,88 @@ +import { expect, test } from '@playwright/test'; +import { readFileSync } from 'fs'; +import { globSync } from 'glob'; + +/** + * Test suite to verify that framework-specific useId hooks are properly + * integrated for SSR compatibility + */ + +test.describe('Framework-specific useId integration', () => { + test('React components should use useId instead of uuid for ID generation', () => { + const reactFiles = globSync('../../output/react/src/components/**/*.tsx'); + + // Check that components with ID generation use useId + const filesWithIdGeneration = reactFiles.filter(file => { + const content = readFileSync(file, 'utf8'); + return content.includes('-${useId()}') || content.includes('-${uuid()}'); + }); + + expect(filesWithIdGeneration.length).toBeGreaterThan(0); + + // Verify that React components use useId, not uuid + filesWithIdGeneration.forEach(file => { + const content = readFileSync(file, 'utf8'); + + if (content.includes('-${')) { + // Should use useId, not uuid + expect(content).toContain('useId()'); + expect(content).toContain('import { useId } from "react"'); + + // Should not use uuid for ID generation patterns + expect(content).not.toMatch(/`[^`]*-\${uuid\(\)}`/); + } + }); + }); + + test('Vue components should use useId instead of uuid for ID generation', () => { + const vueFiles = globSync('../../output/vue/src/components/**/*.vue'); + + // Check that components with ID generation use useId + const filesWithIdGeneration = vueFiles.filter(file => { + const content = readFileSync(file, 'utf8'); + return content.includes('-${useId()}') || content.includes('-${uuid()}'); + }); + + expect(filesWithIdGeneration.length).toBeGreaterThan(0); + + // Verify that Vue components use useId, not uuid + filesWithIdGeneration.forEach(file => { + const content = readFileSync(file, 'utf8'); + + if (content.includes('-${')) { + // Should use useId, not uuid + expect(content).toContain('useId()'); + expect(content).toContain('import { useId } from "vue"'); + + // Should not use uuid for ID generation patterns + expect(content).not.toMatch(/`[^`]*-\${uuid\(\)}`/); + } + }); + }); + + test('Angular and Stencil components should still use uuid for ID generation', () => { + const angularFiles = globSync('../../output/angular/src/**/*.ts'); + const stencilFiles = globSync('../../output/stencil/src/**/*.tsx'); + + [...angularFiles, ...stencilFiles].forEach(file => { + const content = readFileSync(file, 'utf8'); + + if (content.includes('-${') && content.includes('uuid')) { + // Should still use uuid, not useId (since these frameworks don't have useId) + expect(content).toContain('uuid()'); + expect(content).not.toContain('useId()'); + } + }); + }); + + test('Specific components should have correct ID generation patterns', () => { + // Test textarea component specifically + const reactTextarea = readFileSync('../../output/react/src/components/textarea/textarea.tsx', 'utf8'); + expect(reactTextarea).toContain('`textarea-${useId()}`'); + expect(reactTextarea).toContain('import { useId } from "react"'); + + const vueTextarea = readFileSync('../../output/vue/src/components/textarea/textarea.vue', 'utf8'); + expect(vueTextarea).toContain('`textarea-${useId()}`'); + expect(vueTextarea).toContain('import { useId } from "vue"'); + }); +}); \ No newline at end of file diff --git a/packages/components/src/utils/index.ts b/packages/components/src/utils/index.ts index 683a0bdd9cf2..775573a4ad77 100644 --- a/packages/components/src/utils/index.ts +++ b/packages/components/src/utils/index.ts @@ -12,6 +12,19 @@ export const uuid = () => { return Math.random().toString().substring(2); }; +/** + * Generates a unique component ID using framework-specific useId hooks when available. + * This function is designed to be used with useTarget to ensure SSR compatibility. + * + * @param componentPrefix - The component prefix for the ID (e.g., 'textarea') + * @param useIdFn - Framework-specific useId function + * @returns A unique ID string + */ +export const generateComponentId = (componentPrefix: string, useIdFn?: () => string): string => { + const id = useIdFn ? useIdFn() : uuid(); + return `${componentPrefix}-${id}`; +}; + export const addAttributeToChildren = ( element: Element, attribute: { key: string; value: string } diff --git a/packages/components/test/react-ssr-id.spec.tsx b/packages/components/test/react-ssr-id.spec.tsx new file mode 100644 index 000000000000..a22d5724e9c1 --- /dev/null +++ b/packages/components/test/react-ssr-id.spec.tsx @@ -0,0 +1,69 @@ +import { expect, test } from '@playwright/experimental-ct-react'; +import React from 'react'; + +// Import the generated React component +import { DBTextarea } from '../../../output/react/src/components/textarea'; + +/** + * Test to verify that React components use framework-specific useId + * for consistent SSR-compatible ID generation + */ + +test.describe('SSR-compatible ID generation in React', () => { + test('should generate consistent IDs using React useId', async ({ mount }) => { + const Component = () => ( +