diff --git a/.changeset/empty-oranges-exist.md b/.changeset/empty-oranges-exist.md new file mode 100644 index 0000000..a7363cb --- /dev/null +++ b/.changeset/empty-oranges-exist.md @@ -0,0 +1,26 @@ +--- +'react-frame-component': major +--- + +Migrate codebase to TypeScript with React 19 support + +Migrates all source files from JavaScript/JSX to TypeScript/TSX with full type safety. + +**Breaking changes:** + +- Minimum React version bumped from 16.8 to 17.0 + +**New features:** + +- Full TypeScript support with exported `FrameProps` type +- React 19 support added to peer dependencies + +**Deprecations (will be removed in next major version):** + +- `contentDidMount` → use `onMount` instead +- `contentDidUpdate` → use `onUpdate` instead + +**Internal changes:** + +- Content component converted from class to functional component +- Build pipeline updated to emit TypeScript declarations diff --git a/eslint.config.js b/eslint.config.js index a2aea00..b5d23ae 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,4 +1,6 @@ import js from '@eslint/js'; +import ts from '@typescript-eslint/eslint-plugin'; +import tsParser from '@typescript-eslint/parser'; import react from 'eslint-plugin-react'; import reactHooks from 'eslint-plugin-react-hooks'; import reactRefresh from 'eslint-plugin-react-refresh'; @@ -7,7 +9,7 @@ import globals from 'globals'; export default [ js.configs.recommended, { - files: ['**/*.{js,jsx}'], + files: ['**/*.{js,jsx,ts,tsx}'], ignores: [ 'node_modules/**', 'dist/**', @@ -18,6 +20,7 @@ export default [ languageOptions: { ecmaVersion: 'latest', sourceType: 'module', + parser: tsParser, globals: { ...globals.browser }, @@ -29,6 +32,7 @@ export default [ } }, plugins: { + '@typescript-eslint': ts, react, 'react-hooks': reactHooks, 'react-refresh': reactRefresh @@ -52,7 +56,7 @@ export default [ } }, { - files: ['test/**/*.{js,jsx}'], + files: ['test/**/*.{js,jsx,ts,tsx}'], languageOptions: { globals: { ...globals.mocha, diff --git a/index.d.ts b/index.d.ts index 7c6385e..02c858c 100644 --- a/index.d.ts +++ b/index.d.ts @@ -2,19 +2,61 @@ declare module 'react-frame-component' { import * as React from 'react'; export interface FrameComponentProps - extends React.IframeHTMLAttributes, - React.RefAttributes { + extends + Omit, 'children'>, + Omit, 'children'> { head?: React.ReactNode | undefined; mountTarget?: string | undefined; initialContent?: string | undefined; + /** + * @deprecated Use `onMount` instead. Will be removed in a future major version. + */ contentDidMount?: (() => void) | undefined; + /** + * @deprecated Use `onUpdate` instead. Will be removed in a future major version. + */ contentDidUpdate?: (() => void) | undefined; - children: React.ReactNode; + /** + * Called when the iframe content is first mounted and ready. + */ + onMount?: (() => void) | undefined; + /** + * Called when the iframe content updates after the initial mount. + */ + onUpdate?: (() => void) | undefined; + children?: React.ReactNode; + nodeRef?: React.RefObject; } - const FrameComponent: React.ForwardRefExoticComponent; + const FrameComponent: React.ForwardRefExoticComponent< + FrameComponentProps & React.RefAttributes + >; export default FrameComponent; + export function Frame(props: FrameComponentProps): React.ReactElement | null; + + export interface ContentProps { + children?: React.ReactNode; + /** + * @deprecated Use `onMount` instead. + */ + contentDidMount?: () => void; + /** + * @deprecated Use `onUpdate` instead. + */ + contentDidUpdate?: () => void; + /** + * Called when the iframe content is first mounted. + */ + onMount?: () => void; + /** + * Called when the iframe content updates. + */ + onUpdate?: () => void; + } + + export function Content(props: ContentProps): React.ReactElement | null; + export interface FrameContextProps { document?: Document; window?: Window; diff --git a/package-lock.json b/package-lock.json index f88e634..ed34859 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,10 @@ "@playwright/test": "^1.58.2", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@typescript-eslint/eslint-plugin": "^8.56.1", + "@typescript-eslint/parser": "^8.56.1", "@vitejs/plugin-react": "^5.1.4", "@vitest/browser": "^4.0.18", "@vitest/browser-playwright": "^4.0.18", @@ -32,16 +36,15 @@ "globals": "^15.0.0", "html-webpack-plugin": "^5.6.0", "prettier": "^3.2.0", - "prop-types": "^15.8.1", "react": "^18.2.0", "react-dom": "^18.2.0", "rimraf": "^5.0.5", + "typescript": "^5.9.3", "vitest": "^4.0.18", "webpack": "^5.90.0", "webpack-dev-server": "^5.0.0" }, "peerDependencies": { - "prop-types": "^15.8.1 || ^16.0.0 || ^17.0.0 || ^18.0.0", "react": ">= 16.8 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": ">= 16.8 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -4675,6 +4678,28 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, "node_modules/@types/retry": { "version": "0.12.2", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", @@ -4745,6 +4770,302 @@ "@types/node": "*" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", + "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/type-utils": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.56.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", + "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz", + "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.56.1", + "@typescript-eslint/types": "^8.56.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz", + "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz", + "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz", + "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz", + "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz", + "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.56.1", + "@typescript-eslint/tsconfig-utils": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz", + "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz", + "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@vitejs/plugin-react": { "version": "5.1.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", @@ -6344,6 +6665,13 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -12422,6 +12750,19 @@ "node": ">=0.8.0" } }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -12555,6 +12896,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", diff --git a/package.json b/package.json index cde4922..cb58247 100644 --- a/package.json +++ b/package.json @@ -5,13 +5,13 @@ "description": "React component to wrap your application or component in an iFrame for encapsulation purposes", "main": "dist/react-frame-component.umd.js", "module": "dist/react-frame-component.esm.js", - "types": "index.d.ts", + "types": "dist/index.d.ts", "files": [ - "dist", - "index.d.ts" + "dist" ], "exports": { ".": { + "types": "./dist/index.d.ts", "import": "./dist/react-frame-component.esm.js", "require": "./dist/react-frame-component.umd.js" } @@ -24,6 +24,7 @@ "start": "npm run serve", "build": "vite build", "build:example": "vite build --config vite.example.config.js", + "typecheck": "tsc --noEmit", "lint": "eslint src test", "prepublish": "npm run build", "deploy": "gh-pages -d dist", @@ -62,6 +63,10 @@ "@playwright/test": "^1.58.2", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@typescript-eslint/eslint-plugin": "^8.56.1", + "@typescript-eslint/parser": "^8.56.1", "@vitejs/plugin-react": "^5.1.4", "@vitest/browser": "^4.0.18", "@vitest/browser-playwright": "^4.0.18", @@ -74,18 +79,17 @@ "globals": "^15.0.0", "html-webpack-plugin": "^5.6.0", "prettier": "^3.2.0", - "prop-types": "^15.8.1", "react": "^18.2.0", "react-dom": "^18.2.0", "rimraf": "^5.0.5", + "typescript": "^5.9.3", "vitest": "^4.0.18", "webpack": "^5.90.0", "webpack-dev-server": "^5.0.0" }, "peerDependencies": { - "prop-types": "^15.8.1 || ^16.0.0 || ^17.0.0 || ^18.0.0", - "react": ">= 16.8 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": ">= 16.8 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "prettier": { "singleQuote": true, diff --git a/src/Content.jsx b/src/Content.jsx deleted file mode 100644 index 2b4262b..0000000 --- a/src/Content.jsx +++ /dev/null @@ -1,22 +0,0 @@ -import React, { Component, Children } from 'react'; // eslint-disable-line no-unused-vars -import PropTypes from 'prop-types'; - -export default class Content extends Component { - static propTypes = { - children: PropTypes.element.isRequired, - contentDidMount: PropTypes.func.isRequired, - contentDidUpdate: PropTypes.func.isRequired - }; - - componentDidMount() { - this.props.contentDidMount(); - } - - componentDidUpdate() { - this.props.contentDidUpdate(); - } - - render() { - return Children.only(this.props.children); - } -} diff --git a/src/Content.tsx b/src/Content.tsx new file mode 100644 index 0000000..3ce531c --- /dev/null +++ b/src/Content.tsx @@ -0,0 +1,45 @@ +import { Children, ReactNode, useLayoutEffect, useRef } from 'react'; + +type ContentProps = { + children?: ReactNode; + /** + * @deprecated Use `onMount` instead. + */ + contentDidMount?: () => void; + /** + * @deprecated Use `onUpdate` instead. + */ + contentDidUpdate?: () => void; + /** + * Called when the iframe content is first mounted. + */ + onMount?: () => void; + /** + * Called when the iframe content updates. + */ + onUpdate?: () => void; +}; + +export default function Content({ + children, + contentDidMount, + contentDidUpdate, + onMount, + onUpdate +}: ContentProps) { + const isMounted = useRef(false); + + useLayoutEffect(() => { + if (!isMounted.current) { + isMounted.current = true; + contentDidMount?.(); + onMount?.(); + } else { + contentDidUpdate?.(); + onUpdate?.(); + } + }); + + if (!children) return null; + return Children.only(children); +} diff --git a/src/Context.jsx b/src/Context.jsx deleted file mode 100644 index 1b957a6..0000000 --- a/src/Context.jsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; - -let doc; -let win; -if (typeof document !== 'undefined') { - doc = document; -} -if (typeof window !== 'undefined') { - win = window; -} - -export const FrameContext = React.createContext({ document: doc, window: win }); - -export const useFrame = () => React.useContext(FrameContext); - -export const { - Provider: FrameContextProvider, - Consumer: FrameContextConsumer -} = FrameContext; diff --git a/src/Context.tsx b/src/Context.tsx new file mode 100644 index 0000000..bf80fbf --- /dev/null +++ b/src/Context.tsx @@ -0,0 +1,5 @@ +import { FrameContext } from './FrameContext'; + +export const FrameContextConsumer = FrameContext.Consumer; + +export const FrameContextProvider = FrameContext.Provider; diff --git a/src/Frame.jsx b/src/Frame.jsx deleted file mode 100644 index 12402cb..0000000 --- a/src/Frame.jsx +++ /dev/null @@ -1,164 +0,0 @@ -import React, { Component } from 'react'; -import ReactDOM from 'react-dom'; -import PropTypes from 'prop-types'; -import { FrameContextProvider } from './Context'; -import Content from './Content'; - -export class Frame extends Component { - // React warns when you render directly into the body since browser extensions - // also inject into the body and can mess up React. For this reason - // initialContent is expected to have a div inside of the body - // element that we render react into. - static propTypes = { - style: PropTypes.object, - head: PropTypes.node, - initialContent: PropTypes.string, - mountTarget: PropTypes.string, - contentDidMount: PropTypes.func, - contentDidUpdate: PropTypes.func, - children: PropTypes.oneOfType([ - PropTypes.element, - PropTypes.arrayOf(PropTypes.element) - ]) - }; - - static defaultProps = { - style: {}, - head: null, - children: undefined, - mountTarget: undefined, - contentDidMount: () => {}, - contentDidUpdate: () => {}, - initialContent: - '
' - }; - - constructor(props, context) { - super(props, context); - this._isMounted = false; - this.nodeRef = React.createRef(); - this.state = { iframeLoaded: false }; - } - - componentDidMount() { - this._isMounted = true; - - const doc = this.getDoc(); - - if (doc) { - this.nodeRef.current.contentWindow.addEventListener( - 'DOMContentLoaded', - this.handleLoad - ); - } - } - - componentWillUnmount() { - this._isMounted = false; - - this.nodeRef.current.removeEventListener( - 'DOMContentLoaded', - this.handleLoad - ); - } - - getDoc() { - return this.nodeRef.current ? this.nodeRef.current.contentDocument : null; - } - - getMountTarget() { - const doc = this.getDoc(); - if (this.props.mountTarget) { - return doc.querySelector(this.props.mountTarget); - } - return doc.body.children[0]; - } - - setRef = (node) => { - this.nodeRef.current = node; - - const { forwardedRef } = this.props; - if (typeof forwardedRef === 'function') { - forwardedRef(node); - } else if (forwardedRef) { - forwardedRef.current = node; - } - }; - - handleLoad = () => { - clearInterval(this.loadCheck); - // Bail update as some browsers will trigger on both DOMContentLoaded & onLoad ala firefox - if (!this.state.iframeLoaded) { - this.setState({ iframeLoaded: true }); - } - }; - - // In certain situations on a cold cache DOMContentLoaded never gets called - // fallback to an interval to check if that's the case - loadCheck = () => - setInterval(() => { - this.handleLoad(); - }, 500); - - renderFrameContents() { - if (!this._isMounted) { - return null; - } - - const doc = this.getDoc(); - - if (!doc) { - return null; - } - - const contentDidMount = this.props.contentDidMount; - const contentDidUpdate = this.props.contentDidUpdate; - - const win = doc.defaultView || doc.parentView; - const contents = ( - - -
{this.props.children}
-
-
- ); - - const mountTarget = this.getMountTarget(); - - if (!mountTarget) { - return null; - } - - return [ - ReactDOM.createPortal(this.props.head, this.getDoc().head), - ReactDOM.createPortal(contents, mountTarget) - ]; - } - - render() { - const props = { - ...this.props, - srcDoc: this.props.initialContent, - children: undefined // The iframe isn't ready so we drop children from props here. #12, #17 - }; - delete props.head; - delete props.initialContent; - delete props.mountTarget; - delete props.contentDidMount; - delete props.contentDidUpdate; - delete props.forwardedRef; - - return ( - - ); - } -} - -export default React.forwardRef((props, ref) => ( - -)); diff --git a/src/Frame.tsx b/src/Frame.tsx new file mode 100644 index 0000000..b804c5c --- /dev/null +++ b/src/Frame.tsx @@ -0,0 +1,169 @@ +import React, { + CSSProperties, + ReactNode, + RefObject, + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState +} from 'react'; +import { createPortal } from 'react-dom'; +import { FrameContextProvider } from './Context'; +import Content from './Content'; + +export type FrameProps = { + style?: CSSProperties; + head?: ReactNode; + initialContent?: string; + mountTarget?: string; + /** + * @deprecated Use `onMount` instead. Will be removed in a future major version. + */ + contentDidMount?: () => void; + /** + * @deprecated Use `onUpdate` instead. Will be removed in a future major version. + */ + contentDidUpdate?: () => void; + /** + * Called when the iframe content is first mounted and ready. + * Use this instead of the deprecated `contentDidMount` prop. + */ + onMount?: () => void; + /** + * Called when the iframe content updates after the initial mount. + * Use this instead of the deprecated `contentDidUpdate` prop. + */ + onUpdate?: () => void; + children?: ReactNode; + nodeRef?: RefObject; +}; + +const DEFAULT_INITIAL_CONTENT = + '
'; + +function Frame({ + head = null, + children, + mountTarget, + contentDidMount = () => {}, + contentDidUpdate = () => {}, + onMount, + onUpdate, + initialContent = DEFAULT_INITIAL_CONTENT, + nodeRef: externalRef, + ...iframeProps +}: FrameProps) { + const [iframeLoaded, setIframeLoaded] = useState(false); + const internalRef = useRef(null); + const nodeRef = externalRef || internalRef; + const loadCheckInterval = useRef | null>(null); + + const getDoc = useCallback((): Document | null => { + return nodeRef.current ? nodeRef.current.contentDocument : null; + }, [nodeRef]); + + const getMountTarget = (): Element | null => { + const doc = getDoc(); + if (!doc) return null; + + if (mountTarget) { + return doc.querySelector(mountTarget); + } + return doc.body.children[0]; + }; + + const handleLoad = useCallback(() => { + if (loadCheckInterval.current) { + clearInterval(loadCheckInterval.current); + } + if (!iframeLoaded) { + setIframeLoaded(true); + } + }, [iframeLoaded]); + + useEffect(() => { + const doc = getDoc(); + const frame = nodeRef.current; + const interval = loadCheckInterval.current; + + if (doc && frame?.contentWindow) { + frame.contentWindow.addEventListener('DOMContentLoaded', handleLoad); + } + + return () => { + if (frame?.contentWindow) { + frame.contentWindow.removeEventListener('DOMContentLoaded', handleLoad); + } + + if (interval) { + clearInterval(interval); + } + }; + // nodeRef is a ref and should not be in dependencies per React best practices + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [getDoc, handleLoad]); + + const renderFrameContents = (): ReactNode => { + const doc = getDoc(); + + if (!doc) { + return null; + } + + const contents = ( + + +
{children}
+
+
+ ); + + const mountTarget = getMountTarget(); + + if (!mountTarget) { + return null; + } + + return [createPortal(head, doc.head), createPortal(contents, mountTarget)]; + }; + + return ( + + ); +} + +const FrameWithRef = forwardRef( + ({ children, ...props }, ref) => { + const frameRef = useRef(null); + useImperativeHandle( + ref, + () => frameRef.current + ); + return ( + + {children} + + ); + } +); + +FrameWithRef.displayName = 'Frame'; + +export default FrameWithRef; +export { Frame }; diff --git a/src/FrameContext.ts b/src/FrameContext.ts new file mode 100644 index 0000000..13496c8 --- /dev/null +++ b/src/FrameContext.ts @@ -0,0 +1,13 @@ +import { createContext } from 'react'; + +type FrameContextValue = { + document?: Document; + window?: Window; +}; + +const defaultValue: FrameContextValue = { + document: undefined, + window: undefined +}; + +export const FrameContext = createContext(defaultValue); diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 3fdedb5..0000000 --- a/src/index.js +++ /dev/null @@ -1,3 +0,0 @@ -export { default } from './Frame'; - -export { FrameContext, FrameContextConsumer, useFrame } from './Context'; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..018667f --- /dev/null +++ b/src/index.ts @@ -0,0 +1,5 @@ +export { Frame, default } from './Frame'; +export type { FrameProps } from './Frame'; +export { FrameContext } from './FrameContext'; +export { FrameContextConsumer, FrameContextProvider } from './Context'; +export { useFrame } from './useFrame'; diff --git a/src/useFrame.ts b/src/useFrame.ts new file mode 100644 index 0000000..148248a --- /dev/null +++ b/src/useFrame.ts @@ -0,0 +1,4 @@ +import { useContext } from 'react'; +import { FrameContext } from './FrameContext'; + +export const useFrame = () => useContext(FrameContext); diff --git a/test/Content.spec.jsx b/test/Content.spec.jsx index 4c96fa4..7844aa8 100644 --- a/test/Content.spec.jsx +++ b/test/Content.spec.jsx @@ -6,7 +6,7 @@ import Content from '../src/Content'; describe('The Content component', () => { it('should render children', () => { const { container } = render( - null} contentDidUpdate={() => null}> + null} onUpdate={() => null}>
); @@ -15,52 +15,52 @@ describe('The Content component', () => { expect(div.className).toBe('test-class-1'); }); - it('should call contentDidMount on initial render', () => { - const didMount = vi.fn(); - const didUpdate = vi.fn(); + it('should call onMount on initial render', () => { + const onMount = vi.fn(); + const onUpdate = vi.fn(); render( - +
); - expect(didMount).toHaveBeenCalledTimes(1); - expect(didUpdate).toHaveBeenCalledTimes(0); + expect(onMount).toHaveBeenCalledTimes(1); + expect(onUpdate).toHaveBeenCalledTimes(0); }); - it('should call contentDidUpdate on subsequent updates', async () => { - const didMount = vi.fn(); - const didUpdate = vi.fn(); + it('should call onUpdate on subsequent updates', async () => { + const onMount = vi.fn(); + const onUpdate = vi.fn(); const { rerender } = render( - +
); - expect(didUpdate).toHaveBeenCalledTimes(0); + expect(onUpdate).toHaveBeenCalledTimes(0); rerender( - +
); await waitFor(() => { - expect(didMount).toHaveBeenCalledTimes(1); - expect(didUpdate).toHaveBeenCalledTimes(1); + expect(onMount).toHaveBeenCalledTimes(1); + expect(onUpdate).toHaveBeenCalledTimes(1); }); rerender( - +
); await waitFor(() => { - expect(didMount).toHaveBeenCalledTimes(1); - expect(didUpdate).toHaveBeenCalledTimes(2); + expect(onMount).toHaveBeenCalledTimes(1); + expect(onUpdate).toHaveBeenCalledTimes(2); }); }); }); diff --git a/test/Context.spec.jsx b/test/Context.spec.jsx index 0b21065..4297ea7 100644 --- a/test/Context.spec.jsx +++ b/test/Context.spec.jsx @@ -1,12 +1,8 @@ import React from 'react'; import { render } from '@testing-library/react'; import { expect, describe, it } from 'vitest'; -import { - FrameContextProvider, - FrameContextConsumer, - FrameContext, - useFrame -} from '../src/Context'; +import { FrameContextProvider, FrameContextConsumer } from '../src/Context'; +import { useFrame } from '../src/useFrame'; describe('The DocumentContext Component', () => { it('will establish context variables', async () => { @@ -30,21 +26,16 @@ describe('The DocumentContext Component', () => { ); }); - it('exports full context instance to allow accessing via Class.contextType', async () => { + it('exports full context instance to allow accessing via useFrame hook', async () => { const document = { foo: 1 }; const window = { bar: 2 }; - class Child extends React.Component { - componentDidMount() { - const { document: doc, window: win } = this.context; - expect(doc).toEqual({ foo: 1 }); - expect(win).toEqual({ bar: 2 }); - } - render() { - return null; - } - } - Child.contextType = FrameContext; + const Child = () => { + const { document: doc, window: win } = useFrame(); + expect(doc).toEqual({ foo: 1 }); + expect(win).toEqual({ bar: 2 }); + return null; + }; render( diff --git a/test/Frame.spec.jsx b/test/Frame.spec.jsx index ed8fe37..d0d561d 100644 --- a/test/Frame.spec.jsx +++ b/test/Frame.spec.jsx @@ -1,8 +1,8 @@ import React from 'react'; -import PropTypes from 'prop-types'; import { render, waitFor } from '@testing-library/react'; import { expect, vi, describe, it, afterEach, beforeEach } from 'vitest'; import ForwardedRefFrame, { Frame } from '../src/Frame'; +import { FrameContext } from '../src/FrameContext'; describe('The Frame Component', () => { let div; @@ -195,34 +195,21 @@ describe('The Frame Component', () => { }); it('should pass context to components in the frame', async () => { - const Child = (props, context) => ( -
{context.color}
- ); - Child.contextTypes = { - color: PropTypes.string.isRequired + const Child = () => { + const { document: frameDoc, window: frameWin } = + React.useContext(FrameContext); + return ( +
+ {frameDoc ? 'hasDocument' : 'noDocument'}, + {frameWin ? 'hasWindow' : 'noWindow'} +
+ ); }; - class Parent extends React.Component { - static childContextTypes = { - color: PropTypes.string - }; - static propTypes = { - children: PropTypes.element.isRequired - }; - getChildContext() { - return { color: 'purple' }; - } - render() { - return
{this.props.children}
; - } - } - const TestComponent = () => ( - - - - - + + + ); render(); @@ -231,7 +218,7 @@ describe('The Frame Component', () => { const iframe = document.querySelector('iframe'); expect( iframe.contentDocument.body.querySelector('.childDiv').textContent - ).toBe('purple'); + ).toBe('hasDocument,hasWindow'); }); }); @@ -282,46 +269,46 @@ describe('The Frame Component', () => { }); }); - it('should call contentDidMount on initial render', async () => { - const didMount = vi.fn(); - const didUpdate = vi.fn(); + it('should call onMount on initial render', async () => { + const onMount = vi.fn(); + const onUpdate = vi.fn(); - render(); + render(); await waitFor(() => { - expect(didMount).toHaveBeenCalledTimes(1); - expect(didUpdate).toHaveBeenCalledTimes(0); + expect(onMount).toHaveBeenCalledTimes(1); + expect(onUpdate).toHaveBeenCalledTimes(0); }); }); - it('should call contentDidUpdate on subsequent updates', async () => { - const didUpdate = vi.fn(); - const didMount = vi.fn(); + it('should call onUpdate on subsequent updates', async () => { + const onUpdate = vi.fn(); + const onMount = vi.fn(); const { rerender } = render( { - didMount(); + onUpdate={onUpdate} + onMount={() => { + onMount(); }} /> ); await waitFor(() => { - expect(didMount).toHaveBeenCalledTimes(1); + expect(onMount).toHaveBeenCalledTimes(1); }); rerender( { - didMount(); + onUpdate={onUpdate} + onMount={() => { + onMount(); }} /> ); await waitFor(() => { - expect(didUpdate).toHaveBeenCalledTimes(1); + expect(onUpdate).toHaveBeenCalledTimes(1); }); }); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7d7c746 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["DOM", "ES2020"], + "jsx": "react-jsx", + "declaration": true, + "declarationDir": "./dist", + "outDir": "./dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src/**/*", "test/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/vite.config.js b/vite.config.js index d7339d6..14c2108 100644 --- a/vite.config.js +++ b/vite.config.js @@ -8,19 +8,18 @@ export default defineConfig(({ command }) => ({ command === 'build' ? { lib: { - entry: resolve(__dirname, 'src/index.js'), + entry: resolve(__dirname, 'src/index.ts'), name: 'ReactFrameComponent', formats: ['es', 'umd'], fileName: (format) => `react-frame-component.${format === 'es' ? 'esm' : format}.js` }, rollupOptions: { - external: ['react', 'react-dom', 'prop-types'], + external: ['react', 'react-dom'], output: { globals: { react: 'React', - 'react-dom': 'ReactDOM', - 'prop-types': 'PropTypes' + 'react-dom': 'ReactDOM' }, exports: 'named' }