diff --git a/.changeset/witty-planets-lie.md b/.changeset/witty-planets-lie.md new file mode 100644 index 0000000..3c9dc74 --- /dev/null +++ b/.changeset/witty-planets-lie.md @@ -0,0 +1,5 @@ +--- +"@coldwired/react": patch +--- + +add error boundary support diff --git a/packages/react/package.json b/packages/react/package.json index a139f0d..405e5fc 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -2,9 +2,7 @@ "name": "@coldwired/react", "description": "React support for @coldwired", "license": "MIT", - "files": [ - "dist" - ], + "files": ["dist"], "main": "./dist/index.cjs.js", "module": "./dist/index.es.js", "types": "./dist/types/index.d.ts", @@ -34,12 +32,13 @@ "@coldwired/utils": "^0.13.0" }, "devDependencies": { + "react-error-boundary": "^4.0.13", "@coldwired/actions": "*", "@types/react": "^18.2.45", "@types/react-dom": "^18.2.18", "html-entities": "^2.4.0", - "zod": "^3.23.4", - "react-aria-components": "^1.2.0" + "react-aria-components": "^1.2.0", + "zod": "^3.23.4" }, "peerDependencies": { "react": "^18.0.0", @@ -56,24 +55,15 @@ "eslintConfig": { "root": true, "parser": "@typescript-eslint/parser", - "plugins": [ - "@typescript-eslint" - ], - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended", - "prettier" - ], + "plugins": ["@typescript-eslint"], + "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"], "rules": { "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-redeclare": "off" }, "overrides": [ { - "files": [ - "vite.config.js", - "vitest.config.ts" - ], + "files": ["vite.config.js", "vitest.config.ts"], "env": { "node": true } diff --git a/packages/react/src/root.test.tsx b/packages/react/src/root.test.tsx index b8a51cc..30d410c 100644 --- a/packages/react/src/root.test.tsx +++ b/packages/react/src/root.test.tsx @@ -17,7 +17,19 @@ const Counter = () => { ); }; -const manifest: Manifest = { Counter, ComboBox, ListBox, ListBoxItem, Popover, Label, Input }; +const ComponentWithError = () => { + throw new Error('Boom!'); +}; +const manifest: Manifest = { + Counter, + ComponentWithError, + ComboBox, + ListBox, + ListBoxItem, + Popover, + Label, + Input, +}; describe('@coldwired/react', () => { describe('root', () => { @@ -56,6 +68,24 @@ describe('@coldwired/react', () => { root.destroy(); }); + it('render with error boundary', async () => { + document.body.innerHTML = `<${DEFAULT_TAG_NAME}> + <${REACT_COMPONENT_TAG} ${NAME_ATTRIBUTE}="Counter"> + some text <${DEFAULT_TAG_NAME}> + <${REACT_COMPONENT_TAG} ${NAME_ATTRIBUTE}="ComponentWithError"> +
`; + const root = createRoot(document.getElementById('root')!, { + loader: (name) => Promise.resolve(manifest[name]), + }); + await root.mount(); + await root.render(document.body).done; + + expect(document.body.innerHTML).toEqual( + `<${DEFAULT_TAG_NAME}>

Count: 0

some text <${DEFAULT_TAG_NAME}>
Boom!
`, + ); + root.destroy(); + }); + it('render fragment with react aria component', async () => { document.body.innerHTML = `<${DEFAULT_TAG_NAME}> <${REACT_COMPONENT_TAG} ${NAME_ATTRIBUTE}="ComboBox"> diff --git a/packages/react/src/root.ts b/packages/react/src/root.ts index f4ae21e..2dc2a6c 100644 --- a/packages/react/src/root.ts +++ b/packages/react/src/root.ts @@ -9,6 +9,7 @@ import { type ReactNode, type ComponentType, } from 'react'; +import { ErrorBoundary, FallbackProps } from 'react-error-boundary'; import { parseHTMLFragment, isElement } from '@coldwired/utils'; import { @@ -43,6 +44,7 @@ export interface RootOptions { Layout?: ComponentType<{ children: ReactNode }>; manifest?: Manifest; schema?: Partial; + fallbackRender?: FallbackRender; } export interface Schema extends TreeBuilderSchema { @@ -64,6 +66,7 @@ export function createRoot(container: Element, options: RootOptions): Root { const subscriptions = new Set<() => void>(); const manifest: Manifest = Object.assign({}, preloadedManifest); const schema = Object.assign({}, defaultSchema, options.schema); + const fallbackRender = options.fallbackRender ?? defaultFallbackRender; const Layout = options.Layout ?? StrictMode; const notify = () => { @@ -148,7 +151,7 @@ export function createRoot(container: Element, options: RootOptions): Root { createElement( Layout, null, - createElement(RootProvider, { subscribe, getSnapshot, onMounted }), + createElement(RootProvider, { subscribe, getSnapshot, onMounted, fallbackRender }), ), ); @@ -213,14 +216,27 @@ export function createRoot(container: Element, options: RootOptions): Root { }; } +export type FallbackRender = (props: FallbackProps & { element: Element }) => ReactNode; + +const defaultFallbackRender: FallbackRender = ({ error, element }) => { + const message = element.getAttribute('fallback-message') ?? error.message; + return createElement( + 'div', + { role: 'alert' }, + createElement('pre', { style: { color: 'red' } }, message), + ); +}; + function RootProvider({ subscribe, getSnapshot, onMounted, + fallbackRender, }: { subscribe(callback: () => void): () => void; getSnapshot(): Map; onMounted: () => void; + fallbackRender: FallbackRender; }) { useEffect(onMounted, []); const cache = useSyncExternalStore(subscribe, getSnapshot); @@ -229,7 +245,17 @@ function RootProvider({ Fragment, null, ...Array.from(cache).map(([element, content]) => - createPortal(content, element, getKeyForElement(element)), + createPortal( + createElement( + ErrorBoundary, + { + fallbackRender: (props) => fallbackRender({ element, ...props }), + }, + content, + ), + element, + getKeyForElement(element), + ), ), ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f3f64fb..d00bf8e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,6 +106,9 @@ importers: react-aria-components: specifier: ^1.2.0 version: 1.2.0(react-dom@18.2.0)(react@18.2.0) + react-error-boundary: + specifier: ^4.0.13 + version: 4.0.13(react@18.2.0) zod: specifier: ^3.23.4 version: 3.23.4 @@ -217,7 +220,7 @@ packages: /@changesets/apply-release-plan@7.0.0: resolution: {integrity: sha512-vfi69JR416qC9hWmFGSxj7N6wA5J222XNBmezSVATPWDVPIF7gkd4d8CpbEbXmRWbVrkoli3oerGS6dcL/BGsQ==} dependencies: - '@babel/runtime': 7.23.9 + '@babel/runtime': 7.24.4 '@changesets/config': 3.0.0 '@changesets/get-version-range-type': 0.4.0 '@changesets/git': 3.0.0 @@ -235,7 +238,7 @@ packages: /@changesets/assemble-release-plan@6.0.0: resolution: {integrity: sha512-4QG7NuisAjisbW4hkLCmGW2lRYdPrKzro+fCtZaILX+3zdUELSvYjpL4GTv0E4aM9Mef3PuIQp89VmHJ4y2bfw==} dependencies: - '@babel/runtime': 7.23.9 + '@babel/runtime': 7.24.4 '@changesets/errors': 0.2.0 '@changesets/get-dependents-graph': 2.0.0 '@changesets/types': 6.0.0 @@ -318,7 +321,7 @@ packages: /@changesets/get-release-plan@4.0.0: resolution: {integrity: sha512-9L9xCUeD/Tb6L/oKmpm8nyzsOzhdNBBbt/ZNcjynbHC07WW4E1eX8NMGC5g5SbM5z/V+MOrYsJ4lRW41GCbg3w==} dependencies: - '@babel/runtime': 7.23.9 + '@babel/runtime': 7.24.4 '@changesets/assemble-release-plan': 6.0.0 '@changesets/config': 3.0.0 '@changesets/pre': 2.0.0 @@ -334,7 +337,7 @@ packages: /@changesets/git@3.0.0: resolution: {integrity: sha512-vvhnZDHe2eiBNRFHEgMiGd2CT+164dfYyrJDhwwxTVD/OW0FUD6G7+4DIx1dNwkwjHyzisxGAU96q0sVNBns0w==} dependencies: - '@babel/runtime': 7.23.9 + '@babel/runtime': 7.24.4 '@changesets/errors': 0.2.0 '@changesets/types': 6.0.0 '@manypkg/get-packages': 1.1.3 @@ -359,7 +362,7 @@ packages: /@changesets/pre@2.0.0: resolution: {integrity: sha512-HLTNYX/A4jZxc+Sq8D1AMBsv+1qD6rmmJtjsCJa/9MSRybdxh0mjbTvE6JYZQ/ZiQ0mMlDOlGPXTm9KLTU3jyw==} dependencies: - '@babel/runtime': 7.23.9 + '@babel/runtime': 7.24.4 '@changesets/errors': 0.2.0 '@changesets/types': 6.0.0 '@manypkg/get-packages': 1.1.3 @@ -369,7 +372,7 @@ packages: /@changesets/read@0.6.0: resolution: {integrity: sha512-ZypqX8+/im1Fm98K4YcZtmLKgjs1kDQ5zHpc2U1qdtNBmZZfo/IBiG162RoP0CUF05tvp2y4IspH11PLnPxuuw==} dependencies: - '@babel/runtime': 7.23.9 + '@babel/runtime': 7.24.4 '@changesets/git': 3.0.0 '@changesets/logger': 0.1.0 '@changesets/parse': 0.4.0 @@ -390,7 +393,7 @@ packages: /@changesets/write@0.3.0: resolution: {integrity: sha512-slGLb21fxZVUYbyea+94uFiD6ntQW0M2hIKNznFizDhZPDgn2c/fv1UzzlW43RVzh1BEDuIqW6hzlJ1OflNmcw==} dependencies: - '@babel/runtime': 7.23.9 + '@babel/runtime': 7.24.4 '@changesets/types': 6.0.0 fs-extra: 7.0.1 human-id: 1.0.2 @@ -751,7 +754,7 @@ packages: /@manypkg/find-root@1.1.0: resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} dependencies: - '@babel/runtime': 7.23.9 + '@babel/runtime': 7.24.4 '@types/node': 12.20.55 find-up: 4.1.0 fs-extra: 8.1.0 @@ -760,7 +763,7 @@ packages: /@manypkg/get-packages@1.1.3: resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} dependencies: - '@babel/runtime': 7.23.9 + '@babel/runtime': 7.24.4 '@changesets/types': 4.1.0 '@manypkg/find-root': 1.1.0 fs-extra: 8.1.0 @@ -4987,6 +4990,15 @@ packages: react: 18.2.0 scheduler: 0.23.0 + /react-error-boundary@4.0.13(react@18.2.0): + resolution: {integrity: sha512-b6PwbdSv8XeOSYvjt8LpgpKrZ0yGdtZokYwkwV2wlcZbxgopHX/hgPl5VgpnoVOWd868n1hktM8Qm4b+02MiLQ==} + peerDependencies: + react: '>=16.13.1' + dependencies: + '@babel/runtime': 7.24.4 + react: 18.2.0 + dev: true + /react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} dev: true