Let ContentKit webframes dispatch modal and URL actions (#4611)

This commit is contained in:
Nolann B.
2026-09-14 15:19:05 +02:00
committed by GitHub
parent cd881f9ac1
commit 1e8c34a426
8 changed files with 85 additions and 24 deletions
@@ -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 `{}`.
+1
View File
@@ -275,6 +275,7 @@
},
"devDependencies": {
"@types/react": "catalog:",
"bun-types": "catalog:",
"react": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:",
+2
View File
@@ -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"
+13 -10
View File
@@ -192,18 +192,21 @@ export function ContentKit<RenderContext>(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 (
<>
@@ -143,9 +143,9 @@ export function ElementWebframe(props: ContentKitClientElementProps<ContentKitWe
}
break;
default:
renderer.update({
action: message.action,
});
// Go through dispatchAction so webframes can trigger the built-in
// `@ui.*` actions, like a ContentKit button does.
renderer.dispatchAction(message.action);
}
}
};
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'bun:test';
import { resolveDynamicBinding } from './dynamic';
const state = { name: 'Nolann', count: 3, missing: undefined };
describe('resolveDynamicBinding', () => {
it('resolves a binding against the state', () => {
expect(
resolveDynamicBinding<Record<string, unknown>>(state, { label: { $state: 'name' } })
).toEqual({ label: 'Nolann' });
});
it('resolves bindings nested in arrays', () => {
expect(
resolveDynamicBinding<Record<string, unknown>>(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);
});
});
+9 -10
View File
@@ -23,21 +23,20 @@ export function resolveDynamicBinding<T extends {}>(
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];
+2 -1
View File
@@ -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"]