diff --git a/.changeset/rnd-12672-webframe-dispatch-actions.md b/.changeset/rnd-12672-webframe-dispatch-actions.md new file mode 100644 index 000000000..8789232f2 --- /dev/null +++ b/.changeset/rnd-12672-webframe-dispatch-actions.md @@ -0,0 +1,7 @@ +--- +"@gitbook/react-contentkit": patch +--- + +Let ContentKit webframes dispatch the built-in `@ui.*` actions. Actions posted from a webframe were forwarded to the integration as a plain re-render instead of being handled, so `@ui.modal.open`, `@ui.modal.close` and `@ui.url.open` did nothing — most visibly, an integration could not close a modal from inside its own webframe. + +Keep `null` values and class instances such as `Date` intact when resolving dynamic bindings in an action, instead of throwing or flattening them to `{}`. diff --git a/bun.lock b/bun.lock index ef24621df..d524c74fb 100644 --- a/bun.lock +++ b/bun.lock @@ -275,6 +275,7 @@ }, "devDependencies": { "@types/react": "catalog:", + "bun-types": "catalog:", "react": "catalog:", "tsdown": "catalog:", "typescript": "catalog:", diff --git a/packages/react-contentkit/package.json b/packages/react-contentkit/package.json index 381e337cc..895f31a4e 100644 --- a/packages/react-contentkit/package.json +++ b/packages/react-contentkit/package.json @@ -24,6 +24,7 @@ }, "devDependencies": { "@types/react": "catalog:", + "bun-types": "catalog:", "react": "catalog:", "tsdown": "catalog:", "typescript": "catalog:" @@ -31,6 +32,7 @@ "scripts": { "build": "tsdown", "typecheck": "tsc --noEmit", + "unit": "bun test", "dev": "bun run build -- --watch ./src", "clean": "rm -rf ./dist", "publish-to-npm": "../../scripts/publish-if-new.sh" diff --git a/packages/react-contentkit/src/ContentKit.tsx b/packages/react-contentkit/src/ContentKit.tsx index 7a7ccec57..41bda2f31 100644 --- a/packages/react-contentkit/src/ContentKit.tsx +++ b/packages/react-contentkit/src/ContentKit.tsx @@ -192,18 +192,21 @@ export function ContentKit(props: { }; }, [update, security, clientContext, current.state, current.input.context, setCurrent, render]); - const onSubViewAction = React.useCallback(async (action: ContentKitAction) => { - switch (action.action) { - case '@ui.modal.close': { - update({ - action, - }); + const onSubViewAction = React.useCallback( + async (action: ContentKitAction) => { + switch (action.action) { + case '@ui.modal.close': { + update({ + action, + }); - setSubView(null); - break; + setSubView(null); + break; + } } - } - }, []); + }, + [update] + ); return ( <> diff --git a/packages/react-contentkit/src/ElementWebframe.tsx b/packages/react-contentkit/src/ElementWebframe.tsx index 57356c843..62c31c6d3 100644 --- a/packages/react-contentkit/src/ElementWebframe.tsx +++ b/packages/react-contentkit/src/ElementWebframe.tsx @@ -143,9 +143,9 @@ export function ElementWebframe(props: ContentKitClientElementProps { + it('resolves a binding against the state', () => { + expect( + resolveDynamicBinding>(state, { label: { $state: 'name' } }) + ).toEqual({ label: 'Nolann' }); + }); + + it('resolves bindings nested in arrays', () => { + expect( + resolveDynamicBinding>(state, { + items: [{ $state: 'count' }, 'x'], + }) + ).toEqual({ items: [3, 'x'] }); + }); + + it('passes primitives through', () => { + expect(resolveDynamicBinding(state, 'text')).toEqual('text'); + expect(resolveDynamicBinding(state, 42)).toEqual(42); + expect(resolveDynamicBinding(state, false)).toEqual(false); + expect(resolveDynamicBinding(state, { a: undefined })).toEqual({ a: undefined }); + }); + + // Webframes post arbitrary JSON, so `null` is common and used to throw on the `in` check. + it('passes null through, at any depth', () => { + expect(resolveDynamicBinding(state, { action: 'foo', data: null })).toEqual({ + action: 'foo', + data: null, + }); + expect(resolveDynamicBinding(state, { items: [1, null] })).toEqual({ items: [1, null] }); + }); + + // A structured clone carries real Date instances; walking their entries would yield `{}`. + it('leaves class instances intact', () => { + const date = new Date('2020-01-01'); + expect(resolveDynamicBinding(state, { at: date })).toEqual({ at: date }); + }); + + it('leaves an action without any binding unchanged', () => { + const action = { action: 'my.custom.action', props: { a: 1, b: ['x'] } }; + expect(resolveDynamicBinding(state, action)).toEqual(action); + }); +}); diff --git a/packages/react-contentkit/src/dynamic.ts b/packages/react-contentkit/src/dynamic.ts index 540e8c9ab..908fbfb72 100644 --- a/packages/react-contentkit/src/dynamic.ts +++ b/packages/react-contentkit/src/dynamic.ts @@ -23,21 +23,20 @@ export function resolveDynamicBinding( state: object, value: ContentKitDynamicBinding | T ): T { - if ( - typeof value === 'string' || - typeof value === 'number' || - typeof value === 'boolean' || - typeof value === 'undefined' - ) { - // Primitives - return value; - } - if (Array.isArray(value)) { // @ts-ignore return value.map((v) => resolveDynamicBinding(state, v)); } + // Only an object literal can hold a binding: `'$state' in value` throws on `null`, and the + // entries walk below would flatten a class instance (a webframe can post a `Date`) to `{}`. + const prototype = + typeof value === 'object' && value !== null ? Object.getPrototypeOf(value) : undefined; + if (prototype !== Object.prototype && prototype !== null) { + // @ts-ignore + return value; + } + if ('$state' in value && typeof value.$state === 'string') { // @ts-ignore return state[value.$state]; diff --git a/packages/react-contentkit/tsconfig.json b/packages/react-contentkit/tsconfig.json index 3576a5729..60c6d5bf7 100644 --- a/packages/react-contentkit/tsconfig.json +++ b/packages/react-contentkit/tsconfig.json @@ -15,7 +15,8 @@ "resolveJsonModule": true, "isolatedModules": true, "jsx": "react-jsx", - "incremental": true + "incremental": true, + "types": ["bun-types"] }, "include": ["src/**/*.ts", "src/**/*.tsx"], "exclude": ["node_modules"]