Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New canvas bridge implementation #1550

Merged
merged 8 commits into from
Jan 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions packages/toolpad-app/src/canvas/ToolpadBridge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import type { RuntimeEvents } from '@mui/toolpad-core';
import mitt, { Emitter } from 'mitt';
import type { AppCanvasState } from '.';
import type { PageViewState } from '../types';

export const TOOLPAD_BRIDGE_GLOBAL = '__TOOLPAD_BRIDGE__';

declare global {
interface Window {
[TOOLPAD_BRIDGE_GLOBAL]?: ToolpadBridge;
}
}

const COMMAND_HANDLERS = Symbol('hidden property to hold the command handlers');

type Commands<T extends Record<string, Function>> = T & {
[COMMAND_HANDLERS]: Partial<T>;
};

function createCommands<T extends Record<string, Function>>(initial: Partial<T> = {}): Commands<T> {
return new Proxy(
{
[COMMAND_HANDLERS]: initial,
},
{
get(target, prop, receiver) {
if (typeof prop !== 'string') {
return Reflect.get(target, prop, receiver);
}

return (...args: any[]): any => {
const handler = target[COMMAND_HANDLERS][prop];
if (typeof handler !== 'function') {
throw new Error(`Command "${prop}" not recognized.`);
}
return handler(...args);
};
},
},
) as Commands<T>;
}

export function setCommandHandler<T extends Record<string, Function>, K extends keyof T & string>(
commands: Commands<T>,
name: K,
handler: T[K],
) {
if (typeof commands[COMMAND_HANDLERS][name] !== 'undefined') {
throw new Error(`"${name}" is already handled`);
}
commands[COMMAND_HANDLERS][name] = handler;
return () => {
delete commands[COMMAND_HANDLERS][name];
};
}

// Interface to communicate between editor and canvas
export interface ToolpadBridge {
// Events fired in the editor, listened in the canvas
editorEvents: Emitter<{}>;
// Commands executed from the canvas, ran in the editor
editorCommands: Commands<{}>;
// Events fired in the canvas, listened in the editor
canvasEvents: Emitter<RuntimeEvents>;
// Commands executed from the editor, ran in the canvas
canvasCommands: Commands<{
update(updates: AppCanvasState): void;
getViewCoordinates(clientX: number, clientY: number): { x: number; y: number } | null;
getPageViewState(): PageViewState;
isReady(): boolean;
}>;
}

let canvasIsReady = false;
export const bridge: ToolpadBridge = {
editorEvents: mitt(),
editorCommands: createCommands(),
canvasEvents: mitt(),
canvasCommands: createCommands({
isReady: () => canvasIsReady,
}),
} as ToolpadBridge;

bridge.canvasEvents.on('ready', () => {
canvasIsReady = true;
});

if (typeof window !== 'undefined') {
window[TOOLPAD_BRIDGE_GLOBAL] = bridge;
}
90 changes: 42 additions & 48 deletions packages/toolpad-app/src/canvas/index.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,25 @@
import * as React from 'react';
import { fireEvent, setEventHandler } from '@mui/toolpad-core/runtime';
import invariant from 'invariant';
import { throttle } from 'lodash-es';
import { RuntimeEvent } from '@mui/toolpad-core';
import { CanvasEventsContext } from '@mui/toolpad-core/runtime';
import ToolpadApp from '../runtime';
import { NodeHashes, PageViewState, RuntimeState } from '../types';
import { NodeHashes, RuntimeState } from '../types';
import getPageViewState from './getPageViewState';
import { rectContainsPoint } from '../utils/geometry';
import { CanvasHooks, CanvasHooksContext } from '../runtime/CanvasHooksContext';
import { bridge, setCommandHandler } from './ToolpadBridge';

export interface AppCanvasState extends RuntimeState {
savedNodes: NodeHashes;
}

export interface ToolpadBridge {
onRuntimeEvent(handler: (event: RuntimeEvent) => void): void;
update(updates: AppCanvasState): void;
getViewCoordinates(clientX: number, clientY: number): { x: number; y: number } | null;
getPageViewState(): PageViewState;
}

declare global {
interface Window {
__TOOLPAD_BRIDGE__?: ToolpadBridge | ((bridge: ToolpadBridge) => void) | 'consumed';
}
}

const handleScreenUpdate = throttle(() => fireEvent({ type: 'screenUpdate' }), 50, {
trailing: true,
});
const handleScreenUpdate = throttle(
() => {
bridge.canvasEvents.emit('screenUpdate', {});
},
50,
{ trailing: true },
);

export interface AppCanvasProps {
basename: string;
Expand Down Expand Up @@ -87,14 +78,19 @@ export default function AppCanvas({ basename }: AppCanvasProps) {
});

React.useEffect(() => {
const bridge: ToolpadBridge = {
onRuntimeEvent: (handler) => setEventHandler(window, handler),
update: (newState) => React.startTransition(() => setState(newState)),
getPageViewState: () => {
const unsetGetPageViewState = setCommandHandler(
bridge.canvasCommands,
'getPageViewState',
() => {
invariant(appRootRef.current, 'App ref not attached');
return getPageViewState(appRootRef.current);
},
getViewCoordinates(clientX, clientY) {
);

const unsetGetViewCoordinates = setCommandHandler(
bridge.canvasCommands,
'getViewCoordinates',
(clientX, clientY) => {
if (!appRootRef.current) {
return null;
}
Expand All @@ -104,44 +100,42 @@ export default function AppCanvas({ basename }: AppCanvasProps) {
}
return null;
},
};
);

// eslint-disable-next-line no-underscore-dangle
if (typeof window.__TOOLPAD_BRIDGE__ === 'function') {
// eslint-disable-next-line no-underscore-dangle
window.__TOOLPAD_BRIDGE__(bridge);
// eslint-disable-next-line no-underscore-dangle
window.__TOOLPAD_BRIDGE__ = 'consumed';
// eslint-disable-next-line no-underscore-dangle
} else if (typeof window.__TOOLPAD_BRIDGE__ === 'undefined') {
// eslint-disable-next-line no-underscore-dangle
window.__TOOLPAD_BRIDGE__ = bridge;
}
const unsetUpdate = setCommandHandler(bridge.canvasCommands, 'update', (newState) => {
React.startTransition(() => setState(newState));
});

bridge.canvasEvents.emit('ready', {});

return () => {};
return () => {
unsetGetPageViewState();
unsetGetViewCoordinates();
unsetUpdate();
};
}, []);

const savedNodes = state?.savedNodes;
const editorHooks: CanvasHooks = React.useMemo(() => {
return {
savedNodes,
navigateToPage(pageNodeId) {
fireEvent({ type: 'pageNavigationRequest', pageNodeId });
bridge.canvasEvents.emit('pageNavigationRequest', { pageNodeId });
},
};
}, [savedNodes]);

return state ? (
<CanvasHooksContext.Provider value={editorHooks}>
<ToolpadApp
rootRef={onAppRoot}
hidePreviewBanner
version="preview"
basename={`${basename}/${state.appId}`}
state={state}
/>
<CanvasEventsContext.Provider value={bridge.canvasEvents}>
<ToolpadApp
rootRef={onAppRoot}
hidePreviewBanner
version="preview"
basename={`${basename}/${state.appId}`}
state={state}
/>
</CanvasEventsContext.Provider>
</CanvasHooksContext.Provider>
) : (
<div>loading...</div>
);
) : null;
}
21 changes: 7 additions & 14 deletions packages/toolpad-app/src/runtime/ToolpadApp.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import * as React from 'react';
import { render, waitFor as waitForOrig, screen, fireEvent, act } from '@testing-library/react';
import '@testing-library/jest-dom';
import { LiveBindings } from '@mui/toolpad-core';
import { setEventHandler } from '@mui/toolpad-core/runtime';
import { LiveBindings, RuntimeEvents } from '@mui/toolpad-core';
import ToolpadApp from './ToolpadApp';
import * as appDom from '../appDom';
import createRuntimeState from '../createRuntimeState';
import { bridge } from '../canvas/ToolpadBridge';

// More sensible default for these tests
const waitFor: typeof waitForOrig = (waiter, options) =>
Expand Down Expand Up @@ -34,12 +34,6 @@ function renderPage(initPage: (dom: appDom.AppDom, page: appDom.PageNode) => app
return render(<ToolpadApp state={state} version={version} basename="toolpad" />);
}

afterEach(() => {
// Make sure to clean up events after each test
const cleanup = setEventHandler(window, () => {});
cleanup();
});

test(`Static Text`, async () => {
renderPage((dom, page) => {
const text = appDom.createNode(dom, 'element', {
Expand Down Expand Up @@ -131,11 +125,10 @@ test(`Databinding errors`, async () => {
const consoleErrorMock = jest.spyOn(console, 'error').mockImplementation();
let bindings: LiveBindings | undefined;

const cleanup = setEventHandler(window, (event) => {
if (event.type === 'pageBindingsUpdated') {
bindings = event.bindings;
}
});
const bindingsUpdateHandler = (event: RuntimeEvents['pageBindingsUpdated']) => {
bindings = event.bindings;
};
bridge.canvasEvents.on('pageBindingsUpdated', bindingsUpdateHandler);

try {
let nonExisting: appDom.ElementNode;
Expand Down Expand Up @@ -203,7 +196,7 @@ test(`Databinding errors`, async () => {

expect(consoleErrorMock).toHaveBeenCalled();
} finally {
cleanup();
bridge.canvasEvents.off('pageBindingsUpdated', bindingsUpdateHandler);
consoleErrorMock.mockRestore();
}
});
25 changes: 15 additions & 10 deletions packages/toolpad-app/src/runtime/ToolpadApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ import {
} from 'react-router-dom';
import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
import {
fireEvent,
NodeErrorProps,
NodeRuntimeWrapper,
ResetNodeErrorsKeyProvider,
Expand All @@ -54,6 +53,7 @@ import AppThemeProvider from './AppThemeProvider';
import evalJsBindings, {
BindingEvaluationResult,
buildGlobalScope,
EvaluatedBinding,
evaluateExpression,
ParsedBinding,
} from './evalJsBindings';
Expand All @@ -70,6 +70,7 @@ import { useAppContext, AppContextProvider } from './AppContext';
import { CanvasHooksContext, NavigateToPage } from './CanvasHooksContext';
import useBoolean from '../utils/useBoolean';
import { errorFrom } from '../utils/errors';
import { bridge } from '../canvas/ToolpadBridge';

const ReactQueryDevtoolsProduction = React.lazy(() =>
import('@tanstack/react-query-devtools/build/lib/index.prod.js').then((d) => ({
Expand Down Expand Up @@ -588,7 +589,10 @@ interface ParseBindingOptions {
scopePath?: string;
}

function parseBinding(bindable: BindableAttrValue<any>, { scopePath }: ParseBindingOptions = {}) {
function parseBinding(
bindable: BindableAttrValue<any>,
{ scopePath }: ParseBindingOptions = {},
): ParsedBinding | EvaluatedBinding {
if (bindable?.type === 'const') {
return {
scopePath,
Expand All @@ -615,7 +619,7 @@ function parseBindings(
) {
const elements = appDom.getDescendants(dom, page);

const parsedBindingsMap = new Map<string, ParsedBinding>();
const parsedBindingsMap = new Map<string, ParsedBinding | EvaluatedBinding>();
const controlled = new Set<string>();
const globalScopeMeta: GlobalScopeMeta = {};

Expand Down Expand Up @@ -749,7 +753,8 @@ function parseBindings(
});
}

const parsedBindings: Record<string, ParsedBinding> = Object.fromEntries(parsedBindingsMap);
const parsedBindings: Record<string, ParsedBinding | EvaluatedBinding> =
Object.fromEntries(parsedBindingsMap);

return { parsedBindings, controlled, globalScopeMeta };
}
Expand All @@ -770,7 +775,7 @@ function RenderedPage({ nodeId }: RenderedNodeProps) {
);

const [pageBindings, setPageBindings] =
React.useState<Record<string, ParsedBinding>>(parsedBindings);
React.useState<Record<string, ParsedBinding | EvaluatedBinding>>(parsedBindings);

const prevDom = React.useRef(dom);
React.useEffect(() => {
Expand All @@ -784,7 +789,7 @@ function RenderedPage({ nodeId }: RenderedNodeProps) {

setPageBindings((existingBindings) => {
// Make sure to patch page bindings after dom nodes have been added or removed
const updated: Record<string, ParsedBinding> = {};
const updated: Record<string, ParsedBinding | EvaluatedBinding> = {};
for (const [key, binding] of Object.entries(parsedBindings)) {
updated[key] = controlled.has(key) ? existingBindings[key] || binding : binding;
}
Expand All @@ -794,8 +799,8 @@ function RenderedPage({ nodeId }: RenderedNodeProps) {

const setControlledBinding = React.useCallback(
(id: string, result: BindingEvaluationResult) => {
const parsedBinding = parsedBindings[id];
setPageBindings((existing) => {
const { expression, initializer, ...parsedBinding } = parsedBindings[id];
setPageBindings((existing): Record<string, ParsedBinding | EvaluatedBinding> => {
if (!controlled.has(id)) {
throw new Error(`Not a controlled binding "${id}"`);
}
Expand Down Expand Up @@ -833,11 +838,11 @@ function RenderedPage({ nodeId }: RenderedNodeProps) {
);

React.useEffect(() => {
fireEvent({ type: 'pageStateUpdated', pageState, globalScopeMeta });
bridge.canvasEvents.emit('pageStateUpdated', { pageState, globalScopeMeta });
}, [pageState, globalScopeMeta]);

React.useEffect(() => {
fireEvent({ type: 'pageBindingsUpdated', bindings: liveBindings });
bridge.canvasEvents.emit('pageBindingsUpdated', { bindings: liveBindings });
}, [liveBindings]);

return (
Expand Down
Loading