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">${REACT_COMPONENT_TAG}>
+ ${DEFAULT_TAG_NAME}> some text <${DEFAULT_TAG_NAME}>
+ <${REACT_COMPONENT_TAG} ${NAME_ATTRIBUTE}="ComponentWithError">${REACT_COMPONENT_TAG}>
+ ${DEFAULT_TAG_NAME}>
`;
+ 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}>${DEFAULT_TAG_NAME}> some text <${DEFAULT_TAG_NAME}>${DEFAULT_TAG_NAME}>`,
+ );
+ 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