diff --git a/docs/app/docs-infra/components/code-externals-context/types.md b/docs/app/docs-infra/components/code-externals-context/types.md index 256d595ea..472fc1b2c 100644 --- a/docs/app/docs-infra/components/code-externals-context/types.md +++ b/docs/app/docs-infra/components/code-externals-context/types.md @@ -26,5 +26,11 @@ Used by demo client providers to make precomputed externals available to child c type CodeExternalsContext = { /** Map of module specifiers to their imported values (e.g., { 'react': React, '@mui/material': { Button } }) */ externals?: Record; + /** + * Values bound as top-level identifiers in the runner's scope, rather than + * reached through an import (e.g. `{ process: {} }` so a demo referring to + * `process` sees a host-controlled object instead of a `ReferenceError`). + */ + globals?: Record; }; ``` diff --git a/docs/app/docs-infra/components/live-demo-provider/page.mdx b/docs/app/docs-infra/components/live-demo-provider/page.mdx new file mode 100644 index 000000000..bbb1522fb --- /dev/null +++ b/docs/app/docs-infra/components/live-demo-provider/page.mdx @@ -0,0 +1,41 @@ +# Live Demo Provider + +`LiveDemoProvider` makes every demo below it live — the reader's edits are transpiled and rendered — without the generated `client.ts` file that [`createDemoClient`](../../factories/abstract-create-demo-client/page.mdx) needs. A host that already produces static imports for its demos passes them straight in. + +## Usage + +```tsx +import { LiveDemoProvider } from '@mui/internal-docs-infra/LiveDemoProvider'; +import * as Material from '@mui/material'; + + + +; +``` + +The provider composes what a live demo needs into one component: + +- `CodeExternalsContext`, holding the `externals` the demo's imports resolve against. +- `useDemoController`, which owns the controlled source, builds each edited variant off the main thread, and collects per-variant errors. +- `CodeControllerContext`, publishing that controller's `code`, `setCode`, `components`, `errors`, and `onActivate` to the demos below. + +Nothing is controlled until the reader's first edit, so the host's build-time render is what shows until then. + +## Globals + +`externals` covers what a demo _imports_. A demo can also mention a top-level identifier that no import provides — `process` is the usual one — which would otherwise be a `ReferenceError` in the runner. Pass those as `globals`: + +```tsx + + + +``` + +Globals are bound as locals in the runner's scope, so the final scope is `{ ...globals, import: externals }`. Two consequences worth knowing: + +- A global named `import` cannot displace the module registry the `require` shim reads, and `React` and `require` are always injected over any same-named global. +- What you pass is all the demo gets. `{ process: {} }` deliberately exposes nothing, so a demo reaching for `process.env.NODE_ENV` throws — reported as that variant's error rather than taking the page down. + +## Types + + diff --git a/docs/app/docs-infra/components/live-demo-provider/types.md b/docs/app/docs-infra/components/live-demo-provider/types.md new file mode 100644 index 000000000..435f64ce7 --- /dev/null +++ b/docs/app/docs-infra/components/live-demo-provider/types.md @@ -0,0 +1,44 @@ +# Live Demo Provider + +[//]: types.ts '<-- Autogenerated By (do not edit the following markdown directly), run: pnpm docs:validate docs-infra/components/live-demo-provider' + +## API Reference + +### LiveDemoProvider + +Makes the demos below it live, with no generated `client.ts`. + +A host that already produces static imports passes them as `externals` and +wraps its demos; the provider owns the controlled source, builds each edited +variant, and publishes the previews and errors through +`CodeControllerContext` — the same wiring `createDemoClient` performs, minus +the generated file. + +**LiveDemoProvider Props:** + +| Prop | Type | Default | Description | +| :--------- | :------------------------ | :------ | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| externals | `Record` | - | Modules the demo's source can import, keyed by specifier — the same map a generated `client.ts` passes as precomputed externals. | +| globals | `Record` | - | Values bound as top-level identifiers in the runner's scope rather than imported, e.g. `{ process: {} }` so a demo mentioning `process` sees a host-controlled object instead of a `ReferenceError`. | +| children\* | `React.ReactNode` | - | - | + +## Additional Types + +### LiveDemoProviderProps + +```typescript +type LiveDemoProviderProps = { + /** + * Modules the demo's source can import, keyed by specifier — the same map a + * generated `client.ts` passes as precomputed externals. + */ + externals?: Record; + /** + * Values bound as top-level identifiers in the runner's scope rather than + * imported, e.g. `{ process: {} }` so a demo mentioning `process` sees a + * host-controlled object instead of a `ReferenceError`. + */ + globals?: Record; + children: React.ReactNode; +}; +``` diff --git a/docs/app/docs-infra/components/live-demo-provider/types.ts b/docs/app/docs-infra/components/live-demo-provider/types.ts new file mode 100644 index 000000000..171166478 --- /dev/null +++ b/docs/app/docs-infra/components/live-demo-provider/types.ts @@ -0,0 +1,4 @@ +import { createTypes } from '@/functions/createTypes'; +import { LiveDemoProvider } from '@mui/internal-docs-infra/LiveDemoProvider'; + +export const TypesLiveDemoProvider = createTypes(import.meta.url, LiveDemoProvider); diff --git a/docs/app/docs-infra/components/page.mdx b/docs/app/docs-infra/components/page.mdx index 8ae5e7174..b3d6cc8eb 100644 --- a/docs/app/docs-infra/components/page.mdx +++ b/docs/app/docs-infra/components/page.mdx @@ -10,6 +10,7 @@ - Code Externals Context - ([Outline](#code-externals-context), [Contents](./code-externals-context/page.mdx)) - Coordinated Lazy [New] - ([Outline](#coordinated-lazy), [Contents](./coordinated-lazy/page.mdx)) - Chunk Provider [New] - ([Outline](#chunk-provider), [Contents](./chunk-provider/page.mdx)) +- Live Demo Provider [New] - ([Outline](#live-demo-provider), [Contents](./live-demo-provider/page.mdx)) [//]: # 'This section is autogenerated, DO NOT EDIT AFTER THIS LINE, run: pnpm docs:validate docs-infra/components' @@ -279,6 +280,27 @@ Layout-level providers for the [Coordinated Lazy](../../components/coordinated-l [Read more](./chunk-provider/page.mdx) +## Live Demo Provider + +`LiveDemoProvider` makes every demo below it live — the reader's edits are transpiled and rendered — without the generated `client.ts` file that [`createDemoClient`](../../factories/abstract-create-demo-client/page.mdx) needs. A host that already produces static imports for its demos passes them straight in. + +
+ +Outline + +- Sections: + - Usage + - Globals + - Types +- Exports: + - LiveDemoProvider + - Props: children, externals, globals +- Types: LiveDemoProviderProps + +
+ +[Read more](./live-demo-provider/page.mdx) + [//]: # 'The above section is autogenerated, but the remainder of the file can be modified.' diff --git a/packages/docs-infra/package.json b/packages/docs-infra/package.json index 4da5164a5..c7f7a2e82 100644 --- a/packages/docs-infra/package.json +++ b/packages/docs-infra/package.json @@ -18,6 +18,7 @@ "./CodeControllerContext": "./src/CodeControllerContext/index.ts", "./CodeExternalsContext": "./src/CodeExternalsContext/index.ts", "./CodeHighlighter": "./src/CodeHighlighter/index.ts", + "./LiveDemoProvider": "./src/LiveDemoProvider/index.ts", "./CodeHighlighter/types": "./src/CodeHighlighter/types.ts", "./CodeHighlighter/errors": "./src/CodeHighlighter/errors.ts", "./CodeProvider": "./src/CodeProvider/index.ts", diff --git a/packages/docs-infra/src/CodeExternalsContext/CodeExternalsContext.tsx b/packages/docs-infra/src/CodeExternalsContext/CodeExternalsContext.tsx index cc80ec6cb..66448e833 100644 --- a/packages/docs-infra/src/CodeExternalsContext/CodeExternalsContext.tsx +++ b/packages/docs-infra/src/CodeExternalsContext/CodeExternalsContext.tsx @@ -12,6 +12,12 @@ type Module = NonNullable; export interface CodeExternalsContext { /** Map of module specifiers to their imported values (e.g., { 'react': React, '@mui/material': { Button } }) */ externals?: Record; + /** + * Values bound as top-level identifiers in the runner's scope, rather than + * reached through an import (e.g. `{ process: {} }` so a demo referring to + * `process` sees a host-controlled object instead of a `ReferenceError`). + */ + globals?: Record; } /** diff --git a/packages/docs-infra/src/LiveDemoProvider/LiveDemoProvider.test.tsx b/packages/docs-infra/src/LiveDemoProvider/LiveDemoProvider.test.tsx new file mode 100644 index 000000000..9fcd7e039 --- /dev/null +++ b/packages/docs-infra/src/LiveDemoProvider/LiveDemoProvider.test.tsx @@ -0,0 +1,137 @@ +/** + * @vitest-environment jsdom + */ +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +// eslint-disable-next-line testing-library/no-manual-cleanup +import { render, screen, cleanup, act } from '@testing-library/react'; +import { useCodeExternals } from '../CodeExternalsContext'; +import { useControlledCode } from '../CodeControllerContext'; +import { LiveDemoProvider } from './LiveDemoProvider'; + +afterEach(cleanup); + +function Button() { + return null; +} +const externals = { '@mui/material': { Button } }; +const globals = { process: {} }; + +function Probe() { + const externalsContext = useCodeExternals(); + const controller = useControlledCode(); + + return ( + + + {Object.keys(externalsContext?.externals ?? {}).join(',')} + + {Object.keys(externalsContext?.globals ?? {}).join(',')} + + {[ + controller?.setCode ? 'setCode' : '', + 'errors' in (controller ?? {}) ? 'errors' : '', + controller?.onActivate ? 'onActivate' : '', + ] + .filter(Boolean) + .join(',')} + + {controller?.code ? 'edited' : 'original'} + + + ); +} + +describe('LiveDemoProvider', () => { + it('publishes the externals and globals it was given', () => { + render( + + + , + ); + + expect(screen.getByTestId('externals').textContent).toBe('@mui/material'); + expect(screen.getByTestId('globals').textContent).toBe('process'); + }); + + it('publishes a controller, so demos below it are live without a client.ts', () => { + render( + + + , + ); + + expect(screen.getByTestId('controller').textContent).toBe('setCode,errors,onActivate'); + // Nothing is controlled until the first edit, so the host's build-time + // render is what shows. + expect(screen.getByTestId('code').textContent).toBe('original'); + }); + + it('owns the controlled source from the first edit', () => { + render( + + + , + ); + + act(() => { + screen.getByRole('button', { name: 'edit' }).click(); + }); + + expect(screen.getByTestId('code').textContent).toBe('edited'); + }); + + it('defaults to an empty externals map rather than no context', () => { + render( + + + , + ); + + expect(screen.getByTestId('externals').textContent).toBe(''); + expect(screen.getByTestId('globals').textContent).toBe(''); + }); + + // A stable context value matters: it feeds `useDemoController`, and a new + // identity every render would re-run the build effect. + it('does not rebuild the context value on an unrelated re-render', () => { + const values: unknown[] = []; + function Recorder() { + values.push(useCodeExternals()); + return null; + } + function Host({ tick }: { tick: number }) { + return ( + + {tick} + + + ); + } + + const { rerender } = render(); + rerender(); + + expect(values.length).toBeGreaterThan(1); + expect(values[0]).toBe(values[values.length - 1]); + }); +}); + +describe('LiveDemoProvider globals', () => { + it('warns nothing and stays undefined when no globals are passed', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + render( + + + , + ); + + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); +}); diff --git a/packages/docs-infra/src/LiveDemoProvider/LiveDemoProvider.tsx b/packages/docs-infra/src/LiveDemoProvider/LiveDemoProvider.tsx new file mode 100644 index 000000000..776666be3 --- /dev/null +++ b/packages/docs-infra/src/LiveDemoProvider/LiveDemoProvider.tsx @@ -0,0 +1,58 @@ +'use client'; + +import * as React from 'react'; +import { CodeExternalsContext } from '../CodeExternalsContext'; +import type { CodeExternalsContext as CodeExternalsContextValue } from '../CodeExternalsContext'; +import { CodeControllerContext } from '../CodeControllerContext'; +import { useDemoController } from '../useDemoController'; + +export interface LiveDemoProviderProps { + /** + * Modules the demo's source can import, keyed by specifier — the same map a + * generated `client.ts` passes as precomputed externals. + */ + externals?: CodeExternalsContextValue['externals']; + /** + * Values bound as top-level identifiers in the runner's scope rather than + * imported, e.g. `{ process: {} }` so a demo mentioning `process` sees a + * host-controlled object instead of a `ReferenceError`. + */ + globals?: CodeExternalsContextValue['globals']; + children: React.ReactNode; +} + +/** + * Makes the demos below it live, with no generated `client.ts`. + * + * A host that already produces static imports passes them as `externals` and + * wraps its demos; the provider owns the controlled source, builds each edited + * variant, and publishes the previews and errors through + * `CodeControllerContext` — the same wiring `createDemoClient` performs, minus + * the generated file. + */ +export function LiveDemoProvider(props: LiveDemoProviderProps) { + const { externals, globals, children } = props; + + const externalsValue = React.useMemo( + () => ({ externals: externals ?? {}, globals }), + [externals, globals], + ); + + return ( + + {children} + + ); +} + +/** + * Separate component so `useDemoController` reads the externals context this + * provider just installed, rather than whatever was above it. + */ +function LiveDemoController({ children }: { children: React.ReactNode }) { + const controller = useDemoController(); + + return ( + {children} + ); +} diff --git a/packages/docs-infra/src/LiveDemoProvider/index.ts b/packages/docs-infra/src/LiveDemoProvider/index.ts new file mode 100644 index 000000000..8c5cab674 --- /dev/null +++ b/packages/docs-infra/src/LiveDemoProvider/index.ts @@ -0,0 +1 @@ +export * from './LiveDemoProvider'; diff --git a/packages/docs-infra/src/useDemoController/evalCode.test.ts b/packages/docs-infra/src/useDemoController/evalCode.test.ts index af0adcedc..f58eeb9db 100644 --- a/packages/docs-infra/src/useDemoController/evalCode.test.ts +++ b/packages/docs-infra/src/useDemoController/evalCode.test.ts @@ -25,6 +25,22 @@ describe('evalCode', () => { expect(() => evalCode('return 1;', { import: { a: 1 }, default: 'x' })).not.toThrow(); }); + it('binds a host-supplied global, so a demo mentioning it does not throw', () => { + // `LiveDemoProvider`'s `globals` land in the scope, so `process` resolves to + // the host's object instead of a ReferenceError. + expect(evalCode('return typeof process;', { process: {} })).toBe('object'); + expect( + evalCode('return process.env.NODE_ENV;', { process: { env: { NODE_ENV: 'test' } } }), + ).toBe('test'); + }); + + it('throws on a property the supplied global does not carry', () => { + // A host that passes `{ process: {} }` deliberately exposes nothing; reaching + // through it fails at eval, which the runner reports as the variant's error + // rather than taking the page down. + expect(() => evalCode('return process.env.NODE_ENV;', { process: {} })).toThrow(TypeError); + }); + it('injects React and require with precedence over same-named scope entries', () => { // A scope `React`/`require` must NOT shadow the injected bindings: JSX compiles // to `React.*` and transpiled imports call the `require` shim. diff --git a/packages/docs-infra/src/useDemoController/useDemoController.ts b/packages/docs-infra/src/useDemoController/useDemoController.ts index f92761d72..19fb9e0f2 100644 --- a/packages/docs-infra/src/useDemoController/useDemoController.ts +++ b/packages/docs-infra/src/useDemoController/useDemoController.ts @@ -176,7 +176,8 @@ export function useDemoController(): UseDemoControllerResult { } }, []); - const built = useVariantBuilds(code, transpile, externals, reportBuildError); + const globals = externalsContext?.globals; + const built = useVariantBuilds(code, transpile, externals, reportBuildError, globals); const components = React.useMemo(() => { if (!code) { diff --git a/packages/docs-infra/src/useDemoController/useVariantBuilds.test.tsx b/packages/docs-infra/src/useDemoController/useVariantBuilds.test.tsx index 2dd14dacc..5dd2b4154 100644 --- a/packages/docs-infra/src/useDemoController/useVariantBuilds.test.tsx +++ b/packages/docs-infra/src/useDemoController/useVariantBuilds.test.tsx @@ -177,3 +177,41 @@ describe('useVariantBuilds — build error reporting', () => { await waitFor(() => expect(report).toHaveBeenCalledWith('Default', null)); }); }); + +describe('useVariantBuilds — globals', () => { + it('binds globals as top-level identifiers alongside the import registry', async () => { + const { transpile, calls } = makeControllableTranspile(); + const globals = { process: { env: {} } }; + const { result } = renderHook(() => + useVariantBuilds({ Default: variant('ORIG') }, transpile, {}, vi.fn(), globals), + ); + + await waitFor(() => expect(calls).toHaveLength(1)); + await act(async () => { + calls[0].resolve('BUILT'); + }); + + await waitFor(() => expect(result.current.Default).toBeDefined()); + expect(result.current.Default.scope.process).toBe(globals.process); + // `import` is the registry the require shim reads, so a global can never + // displace it. + expect(result.current.Default.scope.import).toBeTypeOf('object'); + }); + + it('keeps the import registry when a global is named `import`', async () => { + const { transpile, calls } = makeControllableTranspile(); + const { result } = renderHook(() => + useVariantBuilds({ Default: variant('ORIG') }, transpile, { dep: 7 }, vi.fn(), { + import: 'hijacked', + }), + ); + + await waitFor(() => expect(calls).toHaveLength(1)); + await act(async () => { + calls[0].resolve('BUILT'); + }); + + await waitFor(() => expect(result.current.Default).toBeDefined()); + expect(result.current.Default.scope.import).toMatchObject({ dep: 7 }); + }); +}); diff --git a/packages/docs-infra/src/useDemoController/useVariantBuilds.ts b/packages/docs-infra/src/useDemoController/useVariantBuilds.ts index afc5a357c..3de4e8a10 100644 --- a/packages/docs-infra/src/useDemoController/useVariantBuilds.ts +++ b/packages/docs-infra/src/useDemoController/useVariantBuilds.ts @@ -58,6 +58,7 @@ export function useVariantBuilds( transpile: Transpile | null, externals: Record, report: (variant: string, message: string | null) => void, + globals?: Record, ): Record { const [built, setBuilt] = React.useState>({}); @@ -151,7 +152,9 @@ export function useVariantBuilds( ...previous, [variant]: { runnerCode: result.runnerCode ?? '', - scope: { import: result.imports }, + // Globals bind as top-level identifiers; `import` is the module + // registry the `require` shim reads, so it always wins. + scope: { ...globals, import: result.imports }, css: result.css, }, })); @@ -198,7 +201,7 @@ export function useVariantBuilds( } startBuild(variant, variantCode); } - }, [code, transpile, externals, report]); + }, [code, transpile, externals, report, globals]); // Abort any in-flight builds on unmount. React.useEffect(