diff --git a/packages/toolpad-app/cli/server.ts b/packages/toolpad-app/cli/server.ts
index 23ae8a89573..c1d6e7e44ac 100644
--- a/packages/toolpad-app/cli/server.ts
+++ b/packages/toolpad-app/cli/server.ts
@@ -11,9 +11,10 @@ import { createServer as createViteServer } from 'vite';
import * as fs from 'fs/promises';
import serializeJavascript from 'serialize-javascript';
import { WebSocket, WebSocketServer } from 'ws';
+import { listen } from '@mui/toolpad-utils/http';
+import { asyncHandler } from '../src/utils/express';
import { createProdHandler } from '../src/server/toolpadAppServer';
import { getUserProjectRoot } from '../src/server/localMode';
-import { asyncHandler, listen } from '../src/utils/http';
import { getProject } from '../src/server/liveProject';
import { Command as AppDevServerCommand, Event as AppDevServerEvent } from './appServer';
import { createRpcHandler, rpcServer } from '../src/server/rpc';
diff --git a/packages/toolpad-app/src/server/data.ts b/packages/toolpad-app/src/server/data.ts
index 4772ac2adc2..49c386c93d9 100644
--- a/packages/toolpad-app/src/server/data.ts
+++ b/packages/toolpad-app/src/server/data.ts
@@ -9,7 +9,7 @@ import serverDataSources from '../toolpadDataSources/server';
import * as appDom from '../appDom';
import applyTransform from '../toolpadDataSources/applyTransform';
import { loadDom, saveDom } from './liveProject';
-import { asyncHandler } from '../utils/http';
+import { asyncHandler } from '../utils/express';
export async function getConnectionParams
(
connectionId: string | null,
diff --git a/packages/toolpad-app/src/server/har.spec.ts b/packages/toolpad-app/src/server/har.spec.ts
index bedacb0436a..4e3a4ed12c7 100644
--- a/packages/toolpad-app/src/server/har.spec.ts
+++ b/packages/toolpad-app/src/server/har.spec.ts
@@ -5,7 +5,7 @@ import { streamToString } from '../utils/streams';
describe('har', () => {
test('headers in array form', async () => {
- const { port, stopServer } = await listen(async (req, res) => {
+ const { port, close } = await listen(async (req, res) => {
res.write(
JSON.stringify({
body: await streamToString(req),
@@ -40,7 +40,7 @@ describe('har', () => {
}),
);
} finally {
- await stopServer();
+ await close();
}
});
});
diff --git a/packages/toolpad-app/src/server/rpc.ts b/packages/toolpad-app/src/server/rpc.ts
index d88ce8f59c1..0ddfb6c5449 100644
--- a/packages/toolpad-app/src/server/rpc.ts
+++ b/packages/toolpad-app/src/server/rpc.ts
@@ -11,7 +11,7 @@ import { execQuery, dataSourceFetchPrivate, dataSourceExecPrivate } from './data
import { getVersionInfo } from './versionInfo';
import { createComponent, deletePage } from './localMode';
import { loadDom, saveDom, applyDomDiff, openCodeEditor } from './liveProject';
-import { asyncHandler } from '../utils/http';
+import { asyncHandler } from '../utils/express';
export interface Method
{
(...params: P): Promise;
diff --git a/packages/toolpad-app/src/server/toolpadAppServer.ts b/packages/toolpad-app/src/server/toolpadAppServer.ts
index 76cc58b8a53..8ca45757c20 100644
--- a/packages/toolpad-app/src/server/toolpadAppServer.ts
+++ b/packages/toolpad-app/src/server/toolpadAppServer.ts
@@ -6,7 +6,7 @@ import config from '../config';
import { postProcessHtml } from './toolpadAppBuilder';
import { loadDom } from './liveProject';
import { getAppOutputFolder } from './localMode';
-import { asyncHandler } from '../utils/http';
+import { asyncHandler } from '../utils/express';
import { createDataHandler } from './data';
import { basicAuthUnauthorized, checkBasicAuthHeader } from './basicAuth';
diff --git a/packages/toolpad-app/src/toolpad/AppEditor/BindingEditor.tsx b/packages/toolpad-app/src/toolpad/AppEditor/BindingEditor.tsx
index 5d93cf5a5e5..315f6886fb3 100644
--- a/packages/toolpad-app/src/toolpad/AppEditor/BindingEditor.tsx
+++ b/packages/toolpad-app/src/toolpad/AppEditor/BindingEditor.tsx
@@ -36,10 +36,10 @@ import {
} from '@mui/toolpad-core';
import { createProvidedContext } from '@mui/toolpad-utils/react';
import { TabContext, TabList } from '@mui/lab';
+import useDebounced from '@mui/toolpad-utils/hooks/useDebounced';
import { JsExpressionEditor } from './PageEditor/JsExpressionEditor';
import JsonView from '../../components/JsonView';
import useLatest from '../../utils/useLatest';
-import useDebounced from '../../utils/useDebounced';
import { useEvaluateLiveBinding } from './useEvaluateLiveBinding';
import GlobalScopeExplorer from './GlobalScopeExplorer';
import { WithControlledProp, Maybe } from '../../utils/types';
diff --git a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/index.tsx b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/index.tsx
index 0ec14461b24..d58c4b0eacc 100644
--- a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/index.tsx
+++ b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/index.tsx
@@ -1,6 +1,7 @@
import * as React from 'react';
import { styled } from '@mui/material';
import { NodeId } from '@mui/toolpad-core';
+import useDebouncedHandler from '@mui/toolpad-utils/hooks/useDebouncedHandler';
import SplitPane from '../../../components/SplitPane';
import RenderPanel from './RenderPanel';
import ComponentPanel from './ComponentPanel';
@@ -11,7 +12,6 @@ import ComponentCatalog from './ComponentCatalog';
import NotFoundEditor from '../NotFoundEditor';
import usePageTitle from '../../../utils/usePageTitle';
import useLocalStorageState from '../../../utils/useLocalStorageState';
-import useDebouncedHandler from '../../../utils/useDebouncedHandler';
import useUndoRedo from '../../hooks/useUndoRedo';
const classes = {
@@ -41,7 +41,10 @@ function PageEditorContent({ node }: PageEditorContentProps) {
300,
);
- const handleSplitChange = useDebouncedHandler((newSize) => setSplitDefaultSize(newSize), 100);
+ const handleSplitChange = useDebouncedHandler(
+ (newSize: number) => setSplitDefaultSize(newSize),
+ 100,
+ );
return (
diff --git a/packages/toolpad-app/src/toolpad/AppState.tsx b/packages/toolpad-app/src/toolpad/AppState.tsx
index a7d0b5b734d..f0f9c0271b4 100644
--- a/packages/toolpad-app/src/toolpad/AppState.tsx
+++ b/packages/toolpad-app/src/toolpad/AppState.tsx
@@ -6,11 +6,11 @@ import { debounce, DebouncedFunc } from 'lodash-es';
import { useLocation } from 'react-router-dom';
import { mapValues } from '@mui/toolpad-utils/collections';
+import useDebouncedHandler from '@mui/toolpad-utils/hooks/useDebouncedHandler';
import * as appDom from '../appDom';
import { omit, update } from '../utils/immutability';
import client from '../api';
import useShortcut from '../utils/useShortcut';
-import useDebouncedHandler from '../utils/useDebouncedHandler';
import insecureHash from '../utils/insecureHash';
import useEvent from '../utils/useEvent';
import { NodeHashes } from '../types';
diff --git a/packages/toolpad-app/src/toolpadDataSources/googleSheets/client.tsx b/packages/toolpad-app/src/toolpadDataSources/googleSheets/client.tsx
index bb1d0bb2175..6fc03ec5b4c 100644
--- a/packages/toolpad-app/src/toolpadDataSources/googleSheets/client.tsx
+++ b/packages/toolpad-app/src/toolpadDataSources/googleSheets/client.tsx
@@ -14,6 +14,7 @@ import { inferColumns, parseColumns } from '@mui/toolpad-components';
import { DataGridPro, GridColDef } from '@mui/x-data-grid-pro';
import { UseQueryResult } from '@tanstack/react-query';
import { getObjectKey } from '@mui/toolpad-utils/objectKey';
+import useDebounced from '@mui/toolpad-utils/hooks/useDebounced';
import { ClientDataSource, ConnectionEditorProps, QueryEditorProps } from '../../types';
import {
GoogleSheetsConnectionParams,
@@ -26,7 +27,6 @@ import {
GoogleSheetsPrivateQuery,
GoogleSheetsResult,
} from './types';
-import useDebounced from '../../utils/useDebounced';
import { usePrivateQuery } from '../context';
import QueryInputPanel from '../QueryInputPanel';
import SplitPane from '../../components/SplitPane';
diff --git a/packages/toolpad-app/src/utils/express.ts b/packages/toolpad-app/src/utils/express.ts
new file mode 100644
index 00000000000..069a1510a65
--- /dev/null
+++ b/packages/toolpad-app/src/utils/express.ts
@@ -0,0 +1,14 @@
+import * as express from 'express';
+import { Awaitable } from './types';
+
+export function asyncHandler(
+ handler: (
+ req: express.Request,
+ res: express.Response,
+ next: express.NextFunction,
+ ) => Awaitable,
+): express.RequestHandler {
+ return (req, res, next) => {
+ Promise.resolve(handler(req, res, next)).catch(next);
+ };
+}
diff --git a/packages/toolpad-app/src/utils/http.ts b/packages/toolpad-app/src/utils/http.ts
deleted file mode 100644
index 43597840dae..00000000000
--- a/packages/toolpad-app/src/utils/http.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import * as express from 'express';
-import * as http from 'http';
-import { Awaitable } from './types';
-
-/**
- * async version of http.Server listen(port) method
- */
-export async function listen(server: http.Server, port?: number) {
- await new Promise((resolve, reject) => {
- const handleError = (err: Error) => {
- reject(err);
- };
- server.once('error', handleError).listen(port, () => {
- server.off('error', handleError);
- resolve();
- });
- });
-}
-
-export function asyncHandler(
- handler: (
- req: express.Request,
- res: express.Response,
- next: express.NextFunction,
- ) => Awaitable,
-): express.RequestHandler {
- return (req, res, next) => {
- Promise.resolve(handler(req, res, next)).catch(next);
- };
-}
diff --git a/packages/toolpad-app/src/utils/useLocalStorageState.ts b/packages/toolpad-app/src/utils/useLocalStorageState.ts
index 78c51557a19..481d4391d99 100644
--- a/packages/toolpad-app/src/utils/useLocalStorageState.ts
+++ b/packages/toolpad-app/src/utils/useLocalStorageState.ts
@@ -1,56 +1,5 @@
import * as React from 'react';
-import { Emitter } from '@mui/toolpad-utils/events';
-
-// storage events only work across windows, we'll use an event emitter to announce within the window
-const emitter = new Emitter>();
-// local cache, needed for getSnapshot
-const cache = new Map();
-
-function subscribe(key: string, cb: () => void): () => void {
- const onKeyChange = () => {
- // invalidate local cache
- cache.delete(key);
- cb();
- };
- const storageHandler = (event: StorageEvent) => {
- if (event.storageArea === window.localStorage && event.key === key) {
- onKeyChange();
- }
- };
- window.addEventListener('storage', storageHandler);
- emitter.on(key, onKeyChange);
- return () => {
- window.removeEventListener('storage', storageHandler);
- emitter.off(key, onKeyChange);
- };
-}
-
-function getSnapshot(key: string): T | undefined {
- try {
- let value = cache.get(key);
- if (!value) {
- const item = window.localStorage.getItem(key);
- value = item ? JSON.parse(item) : undefined;
- cache.set(key, value);
- }
- return value;
- } catch (error) {
- console.error(error);
- return undefined;
- }
-}
-
-function setValue(key: string, value: T) {
- try {
- if (typeof window !== 'undefined') {
- cache.set(key, value);
- window.localStorage.setItem(key, JSON.stringify(value));
- emitter.emit(key, null);
- }
- } catch (error) {
- console.error(error);
- }
-}
+import useStorageState from '@mui/toolpad-utils/hooks/useStorageState';
/**
* Sync state to local storage so that it persists through a page refresh. Usage is
@@ -70,26 +19,16 @@ export default function useLocalStorageState(
key: string,
initialValue: V,
): [V, React.Dispatch>] {
- const subscribeKey = React.useCallback((cb: () => void) => subscribe(key, cb), [key]);
- const getKeySnapshot = React.useCallback(
- () => getSnapshot(key) ?? initialValue,
- [initialValue, key],
- );
- const getKeyServerSnapshot = React.useCallback(() => initialValue, [initialValue]);
-
- const storedValue: V = React.useSyncExternalStore(
- subscribeKey,
- getKeySnapshot,
- getKeyServerSnapshot,
- );
-
- const setStoredValue = React.useCallback(
- (value: React.SetStateAction) => {
- const valueToStore = value instanceof Function ? value(storedValue) : value;
- setValue(key, valueToStore);
- },
- [key, storedValue],
+ const [input, setInput] = useStorageState('local', key, () => JSON.stringify(initialValue));
+
+ const value: V = React.useMemo(() => JSON.parse(input), [input]);
+ const handleChange: React.Dispatch> = React.useCallback(
+ (newValue) =>
+ setInput(
+ JSON.stringify(typeof newValue === 'function' ? (newValue as Function)(value) : newValue),
+ ),
+ [setInput, value],
);
- return [storedValue, setStoredValue];
+ return [value, handleChange];
}
diff --git a/packages/toolpad-app/src/utils/useDebounced.ts b/packages/toolpad-utils/src/hooks/useDebounced.ts
similarity index 100%
rename from packages/toolpad-app/src/utils/useDebounced.ts
rename to packages/toolpad-utils/src/hooks/useDebounced.ts
diff --git a/packages/toolpad-app/src/utils/useDebouncedHandler.ts b/packages/toolpad-utils/src/hooks/useDebouncedHandler.ts
similarity index 83%
rename from packages/toolpad-app/src/utils/useDebouncedHandler.ts
rename to packages/toolpad-utils/src/hooks/useDebouncedHandler.ts
index 08ff850b893..ddbbdc52ee3 100644
--- a/packages/toolpad-app/src/utils/useDebouncedHandler.ts
+++ b/packages/toolpad-utils/src/hooks/useDebouncedHandler.ts
@@ -1,16 +1,16 @@
import * as React from 'react';
-interface Handler {
+interface Handler
{
(...params: P): void;
}
-interface DelayedInvocation
{
+interface DelayedInvocation
{
startTime: number;
timeout: NodeJS.Timeout;
params: P;
}
-function clear
(
+function clear
(
delayedInvocation: React.MutableRefObject | null>,
) {
if (delayedInvocation.current) {
@@ -19,7 +19,11 @@ function clear(
}
}
-function defer
(fn: React.MutableRefObject>, params: P, delay: number) {
+function defer(
+ fn: React.MutableRefObject>,
+ params: P,
+ delay: number,
+) {
const timeout = setTimeout(() => {
fn.current(...params);
}, delay);
@@ -34,7 +38,7 @@ function defer(fn: React.MutableRefObject>, params:
* This implementation adds on the lodash implementation in that it handles updates to the
* delay value.
*/
-export default function useDebouncedHandler(
+export default function useDebouncedHandler
(
fn: Handler
,
delay: number,
): Handler
{
diff --git a/packages/toolpad-utils/src/hooks/useStorageState.ts b/packages/toolpad-utils/src/hooks/useStorageState.ts
new file mode 100644
index 00000000000..1ba5ae74f5c
--- /dev/null
+++ b/packages/toolpad-utils/src/hooks/useStorageState.ts
@@ -0,0 +1,133 @@
+import * as React from 'react';
+import { Emitter } from '../events';
+
+// storage events only work across windows, we'll use an event emitter to announce within the window
+const emitter = new Emitter>();
+// local cache, needed for getSnapshot
+const cache = new Map();
+
+function subscribe(area: Storage, key: string, cb: () => void): () => void {
+ const onKeyChange = () => {
+ // invalidate local cache
+ cache.delete(key);
+ cb();
+ };
+ const storageHandler = (event: StorageEvent) => {
+ if (event.storageArea === area && event.key === key) {
+ onKeyChange();
+ }
+ };
+ window.addEventListener('storage', storageHandler);
+ emitter.on(key, onKeyChange);
+ return () => {
+ window.removeEventListener('storage', storageHandler);
+ emitter.off(key, onKeyChange);
+ };
+}
+
+function getSnapshot(area: Storage, key: string): string | null {
+ let value = cache.get(key) ?? null;
+ if (!value) {
+ const item = area.getItem(key);
+ value = item;
+ if (value === null) {
+ cache.delete(key);
+ } else {
+ cache.set(key, value);
+ }
+ }
+ return value;
+}
+
+function setValue(area: Storage, key: string, value: string | null) {
+ if (typeof window !== 'undefined') {
+ if (value === null) {
+ cache.delete(key);
+ area.removeItem(key);
+ } else {
+ cache.set(key, value);
+ area.setItem(key, String(value));
+ }
+ emitter.emit(key, null);
+ }
+}
+
+type Initializer = () => T;
+
+type UseStorageStateHookResult = [T, React.Dispatch>];
+
+function useStorageStateServer(
+ kind: 'session' | 'local',
+ key: string,
+ initializer: string | Initializer,
+): UseStorageStateHookResult;
+function useStorageStateServer(
+ kind: 'session' | 'local',
+ key: string,
+ initializer?: string | null | Initializer,
+): UseStorageStateHookResult;
+function useStorageStateServer(
+ kind: 'session' | 'local',
+ key: string,
+ initializer: string | null | Initializer = null,
+): UseStorageStateHookResult | UseStorageStateHookResult {
+ const [initialValue] = React.useState(initializer);
+ return [initialValue, () => {}];
+}
+
+/**
+ * Sync state to local/session storage so that it persists through a page refresh. Usage is
+ * similar to useState except we pass in a storage key so that we can default
+ * to that value on page load instead of the specified initial value.
+ *
+ * Since the storage API isn't available in server-rendering environments, we
+ * return initialValue during SSR and hydration.
+ *
+ * Things this hook does different from existing solutions:
+ * - SSR-capable: it shows initial value during SSR and hydration, but immediately
+ * initializes when clientside mounted.
+ * - Sync state across tabs: When another tab changes the value in the storage area, the
+ * current tab follows suit.
+ */
+function useStorageStateBrowser(
+ kind: 'session' | 'local',
+ key: string,
+ initializer: string | Initializer,
+): UseStorageStateHookResult;
+function useStorageStateBrowser(
+ kind: 'session' | 'local',
+ key: string,
+ initializer?: string | null | Initializer,
+): UseStorageStateHookResult;
+function useStorageStateBrowser(
+ kind: 'session' | 'local',
+ key: string,
+ initializer: string | null | Initializer = null,
+): UseStorageStateHookResult | UseStorageStateHookResult {
+ const [initialValue] = React.useState(initializer);
+ const area = kind === 'session' ? window.sessionStorage : window.localStorage;
+ const subscribeKey = React.useCallback((cb: () => void) => subscribe(area, key, cb), [area, key]);
+ const getKeySnapshot = React.useCallback(
+ () => getSnapshot(area, key) ?? initialValue,
+ [area, initialValue, key],
+ );
+ const getKeyServerSnapshot = React.useCallback(() => initialValue, [initialValue]);
+
+ const storedValue = React.useSyncExternalStore(
+ subscribeKey,
+ getKeySnapshot,
+ getKeyServerSnapshot,
+ );
+
+ const setStoredValue = React.useCallback(
+ (value: React.SetStateAction) => {
+ const valueToStore = value instanceof Function ? value(storedValue) : value;
+ setValue(area, key, valueToStore);
+ },
+ [area, key, storedValue],
+ );
+
+ return [storedValue, setStoredValue];
+}
+
+export default typeof window === 'undefined' ? useStorageStateServer : useStorageStateBrowser;
diff --git a/packages/toolpad-utils/src/http.ts b/packages/toolpad-utils/src/http.ts
index 2954385ac69..d5862ca0775 100644
--- a/packages/toolpad-utils/src/http.ts
+++ b/packages/toolpad-utils/src/http.ts
@@ -4,8 +4,8 @@ import invariant from 'invariant';
/**
* A Promise wrapper for server.listen
*/
-export async function listen(handler: http.RequestListener, port?: number) {
- const server = http.createServer(handler);
+export async function listen(handler: http.RequestListener | http.Server, port?: number) {
+ const server = typeof handler === 'function' ? http.createServer(handler) : handler;
let app: http.Server | undefined;
await new Promise((resolve, reject) => {
app = server.listen(port);
@@ -18,7 +18,7 @@ export async function listen(handler: http.RequestListener, port?: number) {
return {
port: address.port,
- async stopServer() {
+ async close() {
await new Promise((resolve, reject) => {
if (app) {
app.close((err) => {
diff --git a/packages/toolpad-utils/src/strings.ts b/packages/toolpad-utils/src/strings.ts
index 31b4ac2452a..1b5e02e439e 100644
--- a/packages/toolpad-utils/src/strings.ts
+++ b/packages/toolpad-utils/src/strings.ts
@@ -164,3 +164,10 @@ export function prependLines(text: string, prefix: string): string {
export function indent(text: string, length = 2): string {
return prependLines(text, ' '.repeat(length));
}
+
+/**
+ * Returns true if the string is a valid javascript identifier
+ */
+export function isValidJsIdentifier(base: string): boolean {
+ return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(base);
+}
diff --git a/test/integration/rest-basic/index.spec.ts b/test/integration/rest-basic/index.spec.ts
index d937f68fa8e..475da3a68c4 100644
--- a/test/integration/rest-basic/index.spec.ts
+++ b/test/integration/rest-basic/index.spec.ts
@@ -27,7 +27,7 @@ test.beforeAll(async ({ localApp }) => {
});
test.afterAll(async () => {
- testServer?.stopServer();
+ testServer?.close();
});
test('rest runtime basics', async ({ page, localApp }) => {
diff --git a/test/visual/components/index.spec.ts b/test/visual/components/index.spec.ts
index 3a4ae42fa28..d661c9b4739 100644
--- a/test/visual/components/index.spec.ts
+++ b/test/visual/components/index.spec.ts
@@ -36,12 +36,10 @@ test('rendering components in the app editor', async ({ page, argosScreenshot })
test('showing grid while resizing elements', async ({ page, argosScreenshot }) => {
const editorModel = new ToolpadEditor(page);
- await editorModel.goto();
+ await editorModel.goToPageById('5YDOftB');
await editorModel.waitForOverlay();
- await editorModel.goToPage('rows');
-
const firstText = editorModel.appCanvas.getByText('text').first();
await clickCenter(page, firstText);