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": { "devDependencies": {
"@types/react": "catalog:", "@types/react": "catalog:",
"bun-types": "catalog:",
"react": "catalog:", "react": "catalog:",
"tsdown": "catalog:", "tsdown": "catalog:",
"typescript": "catalog:", "typescript": "catalog:",
+2
View File
@@ -24,6 +24,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/react": "catalog:", "@types/react": "catalog:",
"bun-types": "catalog:",
"react": "catalog:", "react": "catalog:",
"tsdown": "catalog:", "tsdown": "catalog:",
"typescript": "catalog:" "typescript": "catalog:"
@@ -31,6 +32,7 @@
"scripts": { "scripts": {
"build": "tsdown", "build": "tsdown",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"unit": "bun test",
"dev": "bun run build -- --watch ./src", "dev": "bun run build -- --watch ./src",
"clean": "rm -rf ./dist", "clean": "rm -rf ./dist",
"publish-to-npm": "../../scripts/publish-if-new.sh" "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]); }, [update, security, clientContext, current.state, current.input.context, setCurrent, render]);
const onSubViewAction = React.useCallback(async (action: ContentKitAction) => { const onSubViewAction = React.useCallback(
switch (action.action) { async (action: ContentKitAction) => {
case '@ui.modal.close': { switch (action.action) {
update({ case '@ui.modal.close': {
action, update({
}); action,
});
setSubView(null); setSubView(null);
break; break;
}
} }
} },
}, []); [update]
);
return ( return (
<> <>
@@ -143,9 +143,9 @@ export function ElementWebframe(props: ContentKitClientElementProps<ContentKitWe
} }
break; break;
default: default:
renderer.update({ // Go through dispatchAction so webframes can trigger the built-in
action: message.action, // `@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, state: object,
value: ContentKitDynamicBinding | T value: ContentKitDynamicBinding | T
): T { ): T {
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean' ||
typeof value === 'undefined'
) {
// Primitives
return value;
}
if (Array.isArray(value)) { if (Array.isArray(value)) {
// @ts-ignore // @ts-ignore
return value.map((v) => resolveDynamicBinding(state, v)); 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') { if ('$state' in value && typeof value.$state === 'string') {
// @ts-ignore // @ts-ignore
return state[value.$state]; return state[value.$state];
+2 -1
View File
@@ -15,7 +15,8 @@
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"incremental": true "incremental": true,
"types": ["bun-types"]
}, },
"include": ["src/**/*.ts", "src/**/*.tsx"], "include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules"] "exclude": ["node_modules"]