Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, {}>;
/**
* 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<string, unknown>;
};
```
41 changes: 41 additions & 0 deletions docs/app/docs-infra/components/live-demo-provider/page.mdx
Original file line number Diff line number Diff line change
@@ -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';

<LiveDemoProvider externals={{ '@mui/material': Material }}>
<Demo />
</LiveDemoProvider>;
```

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
<LiveDemoProvider externals={externals} globals={{ process: {} }}>
<Demo />
</LiveDemoProvider>
```

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

<TypesLiveDemoProvider />
44 changes: 44 additions & 0 deletions docs/app/docs-infra/components/live-demo-provider/types.md
Original file line number Diff line number Diff line change
@@ -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<string, {}>` | - | Modules the demo's source can import, keyed by specifier — the same map a&#xA;generated `client.ts` passes as precomputed externals. |
| globals | `Record<string, unknown>` | - | Values bound as top-level identifiers in the runner's scope rather than&#xA;imported, e.g. `{ process: {} }` so a demo mentioning `process` sees a&#xA;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<string, {}>;
/**
* 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<string, unknown>;
children: React.ReactNode;
};
```
4 changes: 4 additions & 0 deletions docs/app/docs-infra/components/live-demo-provider/types.ts
Original file line number Diff line number Diff line change
@@ -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);
22 changes: 22 additions & 0 deletions docs/app/docs-infra/components/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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.

<details>

<summary>Outline</summary>

- Sections:
- Usage
- Globals
- Types
- Exports:
- LiveDemoProvider
- Props: children, externals, globals
- Types: LiveDemoProviderProps

</details>

[Read more](./live-demo-provider/page.mdx)

[//]: # 'The above section is autogenerated, but the remainder of the file can be modified.'

</PagesIndex>
Expand Down
1 change: 1 addition & 0 deletions packages/docs-infra/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ type Module = NonNullable<unknown>;
export interface CodeExternalsContext {
/** Map of module specifiers to their imported values (e.g., { 'react': React, '@mui/material': { Button } }) */
externals?: Record<string, Module>;
/**
* 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<string, unknown>;
}

/**
Expand Down
137 changes: 137 additions & 0 deletions packages/docs-infra/src/LiveDemoProvider/LiveDemoProvider.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<React.Fragment>
<span data-testid="externals">
{Object.keys(externalsContext?.externals ?? {}).join(',')}
</span>
<span data-testid="globals">{Object.keys(externalsContext?.globals ?? {}).join(',')}</span>
<span data-testid="controller">
{[
controller?.setCode ? 'setCode' : '',
'errors' in (controller ?? {}) ? 'errors' : '',
controller?.onActivate ? 'onActivate' : '',
]
.filter(Boolean)
.join(',')}
</span>
<span data-testid="code">{controller?.code ? 'edited' : 'original'}</span>
<button
type="button"
onClick={() => controller?.setCode?.({ Default: { source: 'edited' } })}
>
edit
</button>
</React.Fragment>
);
}

describe('LiveDemoProvider', () => {
it('publishes the externals and globals it was given', () => {
render(
<LiveDemoProvider externals={externals} globals={globals}>
<Probe />
</LiveDemoProvider>,
);

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(
<LiveDemoProvider externals={externals}>
<Probe />
</LiveDemoProvider>,
);

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(
<LiveDemoProvider externals={externals}>
<Probe />
</LiveDemoProvider>,
);

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(
<LiveDemoProvider>
<Probe />
</LiveDemoProvider>,
);

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 (
<LiveDemoProvider externals={externals} globals={globals}>
<span>{tick}</span>
<Recorder />
</LiveDemoProvider>
);
}

const { rerender } = render(<Host tick={1} />);
rerender(<Host tick={2} />);

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(
<LiveDemoProvider externals={externals}>
<Probe />
</LiveDemoProvider>,
);

expect(warn).not.toHaveBeenCalled();
warn.mockRestore();
});
});
58 changes: 58 additions & 0 deletions packages/docs-infra/src/LiveDemoProvider/LiveDemoProvider.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<CodeExternalsContext.Provider value={externalsValue}>
<LiveDemoController>{children}</LiveDemoController>
</CodeExternalsContext.Provider>
);
}

/**
* 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 (
<CodeControllerContext.Provider value={controller}>{children}</CodeControllerContext.Provider>
);
}
1 change: 1 addition & 0 deletions packages/docs-infra/src/LiveDemoProvider/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './LiveDemoProvider';
16 changes: 16 additions & 0 deletions packages/docs-infra/src/useDemoController/evalCode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading