@prairielearn/react 0.0.1 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +0 -0
- package/CHANGELOG.md +7 -0
- package/README.md +68 -29
- package/dist/hydrated-component/index.d.ts +8 -0
- package/dist/hydrated-component/index.d.ts.map +1 -0
- package/dist/hydrated-component/index.js +46 -0
- package/dist/hydrated-component/index.js.map +1 -0
- package/dist/hydrated-component/registry.d.ts +7 -0
- package/dist/hydrated-component/registry.d.ts.map +1 -0
- package/dist/hydrated-component/registry.js +22 -0
- package/dist/hydrated-component/registry.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/server.d.ts +36 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +119 -0
- package/dist/server.js.map +1 -0
- package/package.json +36 -7
- package/src/hydrated-component/index.tsx +52 -0
- package/src/hydrated-component/registry.ts +32 -0
- package/src/index.ts +23 -0
- package/src/server.tsx +164 -0
- package/tsconfig.json +10 -0
|
File without changes
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# @prairielearn/react
|
|
2
|
+
|
|
3
|
+
## 1.0.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 3f79180: Fix React hydration mismatch with `useId()` by rendering hydrated components in an isolated React tree. This ensures hooks like `useId()` generate consistent values between server and client by placing components at the "root" position on both sides.
|
package/README.md
CHANGED
|
@@ -1,45 +1,84 @@
|
|
|
1
|
-
#
|
|
1
|
+
# `@prairielearn/react`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Utilities for rendering React components within PrairieLearn's HTML templating system, including static rendering and client-side hydration.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Usage
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
### Rendering static HTML
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
To render a non-interactive React component to an HTML-safe string, use `renderHtml`:
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
3. Establish provenance for packages published under this name
|
|
11
|
+
```tsx
|
|
12
|
+
import { renderHtml } from '@prairielearn/react/server';
|
|
13
|
+
import { html } from '@prairielearn/html';
|
|
15
14
|
|
|
16
|
-
|
|
15
|
+
function MyComponent() {
|
|
16
|
+
return <div>Hello, world!</div>;
|
|
17
|
+
}
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
const template = html`<div class="container">${renderHtml(<MyComponent />)}</div>`;
|
|
20
|
+
```
|
|
19
21
|
|
|
20
|
-
|
|
22
|
+
To render a complete document with a DOCTYPE declaration, use `renderHtmlDocument`:
|
|
21
23
|
|
|
22
|
-
|
|
24
|
+
```tsx
|
|
25
|
+
import { renderHtmlDocument } from '@prairielearn/react/server';
|
|
23
26
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
const htmlDoc = renderHtmlDocument(
|
|
28
|
+
<html>
|
|
29
|
+
<head>
|
|
30
|
+
<title>My Page</title>
|
|
31
|
+
</head>
|
|
32
|
+
<body>Content</body>
|
|
33
|
+
</html>,
|
|
34
|
+
);
|
|
35
|
+
```
|
|
28
36
|
|
|
29
|
-
|
|
37
|
+
### Rendering components for client-side hydration
|
|
30
38
|
|
|
31
|
-
|
|
32
|
-
- Contains no executable code
|
|
33
|
-
- Provides no functionality
|
|
34
|
-
- Should not be installed as a dependency
|
|
35
|
-
- Exists only for administrative purposes
|
|
39
|
+
Interactive components that require client-side JavaScript must be wrapped in a `<Hydrate>` component. This sets up the necessary HTML structure and data attributes for hydration.
|
|
36
40
|
|
|
37
|
-
|
|
41
|
+
The root component must live in a module that can be imported on the client, and it must have a `displayName` property set. This is used to identify the component during hydration.
|
|
38
42
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
- [GitHub Actions OIDC Documentation](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect)
|
|
43
|
+
```tsx
|
|
44
|
+
import { Hydrate } from '@prairielearn/react/server';
|
|
42
45
|
|
|
43
|
-
|
|
46
|
+
function InteractiveComponent({ name }: { name: string }) {
|
|
47
|
+
return <button onClick={() => alert(`Hello, ${name}!`)}>Click me</button>;
|
|
48
|
+
}
|
|
44
49
|
|
|
45
|
-
|
|
50
|
+
InteractiveComponent.displayName = 'InteractiveComponent';
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
When rendering the page, wrap the component in `<Hydrate>`:
|
|
54
|
+
|
|
55
|
+
```tsx
|
|
56
|
+
<Hydrate>
|
|
57
|
+
<InteractiveComponent name="Alice" />
|
|
58
|
+
</Hydrate>
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Alternatively, you can use the `hydrateHtml` convenience function to produce an HTML-safe string directly:
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
64
|
+
import { hydrateHtml } from '@prairielearn/react/server';
|
|
65
|
+
import { html } from '@prairielearn/html';
|
|
66
|
+
|
|
67
|
+
const template = html`
|
|
68
|
+
<div class="container">${hydrateHtml(<InteractiveComponent name="Alice" />)}</div>
|
|
69
|
+
`;
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
This will render the component to HTML, serialize the component's props using `superjson`, and produce markup that the client can use to hydrate the component.
|
|
73
|
+
|
|
74
|
+
**Important**: all serialized props will be visible on the client, so avoid passing sensitive data. The main PrairieLearn application includes Zod schemas to strip down data structures before passing them to hydrated components (e.g. `apps/prairielearn/src/lib/client/safe-db-types.ts` and `apps/prairielearn/src/lib/client/page-context.ts`).
|
|
75
|
+
|
|
76
|
+
Hydration relies on `@prairielearn/compiled-assets` to produce the necessary client-side bundles, and there are conventions that must be followed. Specifically, you must create a file in `assets/scripts/esm-bundles/hydrated-components`, and the file's name must match the `displayName` of the component to be hydrated. For the above example, the file would be `assets/scripts/esm-bundles/hydrated-components/InteractiveComponent.ts`. It must contain a call to `registerHydratedComponent` with the component that will be hydrated:
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import { registerHydratedComponent } from '@prairielearn/react/hydrated-component';
|
|
80
|
+
|
|
81
|
+
import { InteractiveComponent } from '../../../../src/components/InteractiveComponent.js';
|
|
82
|
+
|
|
83
|
+
registerHydratedComponent(InteractiveComponent);
|
|
84
|
+
```
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ComponentType } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Registers a React component for client-side hydration. The component should have a
|
|
4
|
+
* `displayName` property. If it's missing, or the name of the component bundle differs,
|
|
5
|
+
* you can provide a `nameOverride`.
|
|
6
|
+
*/
|
|
7
|
+
export declare function registerHydratedComponent(component: ComponentType<any>, nameOverride?: string): void;
|
|
8
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/hydrated-component/index.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,OAAO,CAAC;AAc3C;;;;GAIG;AACH,wBAAgB,yBAAyB,CAAC,SAAS,EAAE,aAAa,CAAC,GAAG,CAAC,EAAE,YAAY,CAAC,EAAE,MAAM,QAQ7F","sourcesContent":["import type { ComponentType } from 'react';\nimport { hydrateRoot } from 'react-dom/client';\nimport { observe } from 'selector-observer';\nimport superjson from 'superjson';\n\nimport { onDocumentReady } from '@prairielearn/browser-utils';\n\nimport { HydratedComponentsRegistry } from './registry.js';\n\n// This file, if imported, will register a selector observer that will hydrate\n// registered components on the client.\n\nconst registry = new HydratedComponentsRegistry();\n\n/**\n * Registers a React component for client-side hydration. The component should have a\n * `displayName` property. If it's missing, or the name of the component bundle differs,\n * you can provide a `nameOverride`.\n */\nexport function registerHydratedComponent(component: ComponentType<any>, nameOverride?: string) {\n // Each React component that will be hydrated on the page must be registered.\n // Note that we don't try to use `component.name` since it can be minified or mangled.\n const id = nameOverride ?? component.displayName;\n if (!id) {\n throw new Error('React fragment must have a displayName or nameOverride');\n }\n registry.setComponent(id, component);\n}\n\nonDocumentReady(() => {\n observe('.js-hydrated-component', {\n async add(el) {\n const componentName = el.getAttribute('data-component');\n if (!componentName) {\n throw new Error('js-hydrated-component element must have a data-component attribute');\n }\n\n // If you forget to register a component with `registerHydratedComponent`, this is going to hang.\n const Component = await registry.getComponent(componentName);\n\n const dataElement = el.previousElementSibling;\n if (!dataElement) throw new Error('No data element found');\n if (!dataElement.hasAttribute('data-component-props')) {\n throw new Error('Data element is missing data-component-props attribute');\n }\n if (!dataElement.textContent) throw new Error('Data element has no content');\n const data: object = superjson.parse(dataElement.textContent);\n\n hydrateRoot(el, <Component {...data} />);\n },\n });\n});\n"]}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { hydrateRoot } from 'react-dom/client';
|
|
3
|
+
import { observe } from 'selector-observer';
|
|
4
|
+
import superjson from 'superjson';
|
|
5
|
+
import { onDocumentReady } from '@prairielearn/browser-utils';
|
|
6
|
+
import { HydratedComponentsRegistry } from './registry.js';
|
|
7
|
+
// This file, if imported, will register a selector observer that will hydrate
|
|
8
|
+
// registered components on the client.
|
|
9
|
+
const registry = new HydratedComponentsRegistry();
|
|
10
|
+
/**
|
|
11
|
+
* Registers a React component for client-side hydration. The component should have a
|
|
12
|
+
* `displayName` property. If it's missing, or the name of the component bundle differs,
|
|
13
|
+
* you can provide a `nameOverride`.
|
|
14
|
+
*/
|
|
15
|
+
export function registerHydratedComponent(component, nameOverride) {
|
|
16
|
+
// Each React component that will be hydrated on the page must be registered.
|
|
17
|
+
// Note that we don't try to use `component.name` since it can be minified or mangled.
|
|
18
|
+
const id = nameOverride ?? component.displayName;
|
|
19
|
+
if (!id) {
|
|
20
|
+
throw new Error('React fragment must have a displayName or nameOverride');
|
|
21
|
+
}
|
|
22
|
+
registry.setComponent(id, component);
|
|
23
|
+
}
|
|
24
|
+
onDocumentReady(() => {
|
|
25
|
+
observe('.js-hydrated-component', {
|
|
26
|
+
async add(el) {
|
|
27
|
+
const componentName = el.getAttribute('data-component');
|
|
28
|
+
if (!componentName) {
|
|
29
|
+
throw new Error('js-hydrated-component element must have a data-component attribute');
|
|
30
|
+
}
|
|
31
|
+
// If you forget to register a component with `registerHydratedComponent`, this is going to hang.
|
|
32
|
+
const Component = await registry.getComponent(componentName);
|
|
33
|
+
const dataElement = el.previousElementSibling;
|
|
34
|
+
if (!dataElement)
|
|
35
|
+
throw new Error('No data element found');
|
|
36
|
+
if (!dataElement.hasAttribute('data-component-props')) {
|
|
37
|
+
throw new Error('Data element is missing data-component-props attribute');
|
|
38
|
+
}
|
|
39
|
+
if (!dataElement.textContent)
|
|
40
|
+
throw new Error('Data element has no content');
|
|
41
|
+
const data = superjson.parse(dataElement.textContent);
|
|
42
|
+
hydrateRoot(el, _jsx(Component, { ...data }));
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/hydrated-component/index.tsx"],"names":[],"mappings":";AACA,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAC/C,OAAO,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAC5C,OAAO,SAAS,MAAM,WAAW,CAAC;AAElC,OAAO,EAAE,eAAe,EAAE,MAAM,6BAA6B,CAAC;AAE9D,OAAO,EAAE,0BAA0B,EAAE,MAAM,eAAe,CAAC;AAE3D,8EAA8E;AAC9E,uCAAuC;AAEvC,MAAM,QAAQ,GAAG,IAAI,0BAA0B,EAAE,CAAC;AAElD;;;;GAIG;AACH,MAAM,UAAU,yBAAyB,CAAC,SAA6B,EAAE,YAAqB,EAAE;IAC9F,6EAA6E;IAC7E,sFAAsF;IACtF,MAAM,EAAE,GAAG,YAAY,IAAI,SAAS,CAAC,WAAW,CAAC;IACjD,IAAI,CAAC,EAAE,EAAE,CAAC;QACR,MAAM,IAAI,KAAK,CAAC,wDAAwD,CAAC,CAAC;IAC5E,CAAC;IACD,QAAQ,CAAC,YAAY,CAAC,EAAE,EAAE,SAAS,CAAC,CAAC;AAAA,CACtC;AAED,eAAe,CAAC,GAAG,EAAE,CAAC;IACpB,OAAO,CAAC,wBAAwB,EAAE;QAChC,KAAK,CAAC,GAAG,CAAC,EAAE,EAAE;YACZ,MAAM,aAAa,GAAG,EAAE,CAAC,YAAY,CAAC,gBAAgB,CAAC,CAAC;YACxD,IAAI,CAAC,aAAa,EAAE,CAAC;gBACnB,MAAM,IAAI,KAAK,CAAC,oEAAoE,CAAC,CAAC;YACxF,CAAC;YAED,iGAAiG;YACjG,MAAM,SAAS,GAAG,MAAM,QAAQ,CAAC,YAAY,CAAC,aAAa,CAAC,CAAC;YAE7D,MAAM,WAAW,GAAG,EAAE,CAAC,sBAAsB,CAAC;YAC9C,IAAI,CAAC,WAAW;gBAAE,MAAM,IAAI,KAAK,CAAC,uBAAuB,CAAC,CAAC;YAC3D,IAAI,CAAC,WAAW,CAAC,YAAY,CAAC,sBAAsB,CAAC,EAAE,CAAC;gBACtD,MAAM,IAAI,KAAK,CAAC,wDAAwD,CAAC,CAAC;YAC5E,CAAC;YACD,IAAI,CAAC,WAAW,CAAC,WAAW;gBAAE,MAAM,IAAI,KAAK,CAAC,6BAA6B,CAAC,CAAC;YAC7E,MAAM,IAAI,GAAW,SAAS,CAAC,KAAK,CAAC,WAAW,CAAC,WAAW,CAAC,CAAC;YAE9D,WAAW,CAAC,EAAE,EAAE,KAAC,SAAS,OAAK,IAAI,GAAI,CAAC,CAAC;QAAA,CAC1C;KACF,CAAC,CAAC;AAAA,CACJ,CAAC,CAAC","sourcesContent":["import type { ComponentType } from 'react';\nimport { hydrateRoot } from 'react-dom/client';\nimport { observe } from 'selector-observer';\nimport superjson from 'superjson';\n\nimport { onDocumentReady } from '@prairielearn/browser-utils';\n\nimport { HydratedComponentsRegistry } from './registry.js';\n\n// This file, if imported, will register a selector observer that will hydrate\n// registered components on the client.\n\nconst registry = new HydratedComponentsRegistry();\n\n/**\n * Registers a React component for client-side hydration. The component should have a\n * `displayName` property. If it's missing, or the name of the component bundle differs,\n * you can provide a `nameOverride`.\n */\nexport function registerHydratedComponent(component: ComponentType<any>, nameOverride?: string) {\n // Each React component that will be hydrated on the page must be registered.\n // Note that we don't try to use `component.name` since it can be minified or mangled.\n const id = nameOverride ?? component.displayName;\n if (!id) {\n throw new Error('React fragment must have a displayName or nameOverride');\n }\n registry.setComponent(id, component);\n}\n\nonDocumentReady(() => {\n observe('.js-hydrated-component', {\n async add(el) {\n const componentName = el.getAttribute('data-component');\n if (!componentName) {\n throw new Error('js-hydrated-component element must have a data-component attribute');\n }\n\n // If you forget to register a component with `registerHydratedComponent`, this is going to hang.\n const Component = await registry.getComponent(componentName);\n\n const dataElement = el.previousElementSibling;\n if (!dataElement) throw new Error('No data element found');\n if (!dataElement.hasAttribute('data-component-props')) {\n throw new Error('Data element is missing data-component-props attribute');\n }\n if (!dataElement.textContent) throw new Error('Data element has no content');\n const data: object = superjson.parse(dataElement.textContent);\n\n hydrateRoot(el, <Component {...data} />);\n },\n });\n});\n"]}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ComponentType } from 'react';
|
|
2
|
+
export declare class HydratedComponentsRegistry {
|
|
3
|
+
private components;
|
|
4
|
+
setComponent(id: string, component: ComponentType<any>): void;
|
|
5
|
+
getComponent(id: string): Promise<ComponentType<any>>;
|
|
6
|
+
}
|
|
7
|
+
//# sourceMappingURL=registry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/hydrated-component/registry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,OAAO,CAAC;AAQ3C,qBAAa,0BAA0B;IACrC,OAAO,CAAC,UAAU,CAAyE;IAE3F,YAAY,CAAC,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,aAAa,CAAC,GAAG,CAAC,QAUrD;IAED,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,GAAG,CAAC,CAAC,CAOpD;CACF","sourcesContent":["import type { ComponentType } from 'react';\n\nimport { type PromiseWithResolvers, withResolvers } from '@prairielearn/utils';\n\ntype AugmentedPromiseWithResolvers<T> = PromiseWithResolvers<T> & {\n resolved: boolean;\n};\n\nexport class HydratedComponentsRegistry {\n private components: Record<string, AugmentedPromiseWithResolvers<ComponentType<any>>> = {};\n\n setComponent(id: string, component: ComponentType<any>) {\n if (this.components[id]?.resolved) {\n throw new Error(`React fragment with id ${id} already resolved`);\n }\n\n if (!this.components[id]) {\n this.components[id] = { ...withResolvers<ComponentType<any>>(), resolved: false };\n }\n this.components[id].resolve(component);\n this.components[id].resolved = true;\n }\n\n getComponent(id: string): Promise<ComponentType<any>> {\n if (!this.components[id]) {\n // This promise will be resolved later when the component is registered via `setFragment`.\n this.components[id] = { ...withResolvers<ComponentType<any>>(), resolved: false };\n }\n\n return this.components[id].promise;\n }\n}\n"]}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { withResolvers } from '@prairielearn/utils';
|
|
2
|
+
export class HydratedComponentsRegistry {
|
|
3
|
+
components = {};
|
|
4
|
+
setComponent(id, component) {
|
|
5
|
+
if (this.components[id]?.resolved) {
|
|
6
|
+
throw new Error(`React fragment with id ${id} already resolved`);
|
|
7
|
+
}
|
|
8
|
+
if (!this.components[id]) {
|
|
9
|
+
this.components[id] = { ...withResolvers(), resolved: false };
|
|
10
|
+
}
|
|
11
|
+
this.components[id].resolve(component);
|
|
12
|
+
this.components[id].resolved = true;
|
|
13
|
+
}
|
|
14
|
+
getComponent(id) {
|
|
15
|
+
if (!this.components[id]) {
|
|
16
|
+
// This promise will be resolved later when the component is registered via `setFragment`.
|
|
17
|
+
this.components[id] = { ...withResolvers(), resolved: false };
|
|
18
|
+
}
|
|
19
|
+
return this.components[id].promise;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=registry.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"registry.js","sourceRoot":"","sources":["../../src/hydrated-component/registry.ts"],"names":[],"mappings":"AAEA,OAAO,EAA6B,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAM/E,MAAM,OAAO,0BAA0B;IAC7B,UAAU,GAAsE,EAAE,CAAC;IAE3F,YAAY,CAAC,EAAU,EAAE,SAA6B,EAAE;QACtD,IAAI,IAAI,CAAC,UAAU,CAAC,EAAE,CAAC,EAAE,QAAQ,EAAE,CAAC;YAClC,MAAM,IAAI,KAAK,CAAC,0BAA0B,EAAE,mBAAmB,CAAC,CAAC;QACnE,CAAC;QAED,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,EAAE,CAAC,EAAE,CAAC;YACzB,IAAI,CAAC,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,aAAa,EAAsB,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC;QACpF,CAAC;QACD,IAAI,CAAC,UAAU,CAAC,EAAE,CAAC,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;QACvC,IAAI,CAAC,UAAU,CAAC,EAAE,CAAC,CAAC,QAAQ,GAAG,IAAI,CAAC;IAAA,CACrC;IAED,YAAY,CAAC,EAAU,EAA+B;QACpD,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,EAAE,CAAC,EAAE,CAAC;YACzB,0FAA0F;YAC1F,IAAI,CAAC,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,aAAa,EAAsB,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC;QACpF,CAAC;QAED,OAAO,IAAI,CAAC,UAAU,CAAC,EAAE,CAAC,CAAC,OAAO,CAAC;IAAA,CACpC;CACF","sourcesContent":["import type { ComponentType } from 'react';\n\nimport { type PromiseWithResolvers, withResolvers } from '@prairielearn/utils';\n\ntype AugmentedPromiseWithResolvers<T> = PromiseWithResolvers<T> & {\n resolved: boolean;\n};\n\nexport class HydratedComponentsRegistry {\n private components: Record<string, AugmentedPromiseWithResolvers<ComponentType<any>>> = {};\n\n setComponent(id: string, component: ComponentType<any>) {\n if (this.components[id]?.resolved) {\n throw new Error(`React fragment with id ${id} already resolved`);\n }\n\n if (!this.components[id]) {\n this.components[id] = { ...withResolvers<ComponentType<any>>(), resolved: false };\n }\n this.components[id].resolve(component);\n this.components[id].resolved = true;\n }\n\n getComponent(id: string): Promise<ComponentType<any>> {\n if (!this.components[id]) {\n // This promise will be resolved later when the component is registered via `setFragment`.\n this.components[id] = { ...withResolvers<ComponentType<any>>(), resolved: false };\n }\n\n return this.components[id].promise;\n }\n}\n"]}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { HtmlSafeString } from '@prairielearn/html';
|
|
3
|
+
/**
|
|
4
|
+
* Render a non-interactive React component that is embedded within a tagged template literal.
|
|
5
|
+
* This function is intended to be used within a tagged template literal, e.g. html`...`.
|
|
6
|
+
*
|
|
7
|
+
* @param node - Contents to render to HTML.
|
|
8
|
+
* @returns An `HtmlSafeString` containing the rendered HTML.
|
|
9
|
+
*/
|
|
10
|
+
export declare function renderHtml(node: ReactNode | string): HtmlSafeString;
|
|
11
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAGvC,OAAO,EAAE,cAAc,EAA0B,MAAM,oBAAoB,CAAC;AAM5E;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,SAAS,GAAG,MAAM,GAAG,cAAc,CAMnE","sourcesContent":["import type { ReactNode } from 'react';\nimport { renderToString } from 'react-dom/server';\n\nimport { HtmlSafeString, escapeHtml, unsafeHtml } from '@prairielearn/html';\n\n// These functions are separated from the other utilities so that they can be imported\n// on both the client and the server. `server.tsx` imports `@prairielearn/compiled-assets`,\n// which cannot be bundled for the browser.\n\n/**\n * Render a non-interactive React component that is embedded within a tagged template literal.\n * This function is intended to be used within a tagged template literal, e.g. html`...`.\n *\n * @param node - Contents to render to HTML.\n * @returns An `HtmlSafeString` containing the rendered HTML.\n */\nexport function renderHtml(node: ReactNode | string): HtmlSafeString {\n if (typeof node === 'string') {\n return escapeHtml(new HtmlSafeString([node], []));\n }\n\n return unsafeHtml(renderToString(node));\n}\n"]}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { renderToString } from 'react-dom/server';
|
|
2
|
+
import { HtmlSafeString, escapeHtml, unsafeHtml } from '@prairielearn/html';
|
|
3
|
+
// These functions are separated from the other utilities so that they can be imported
|
|
4
|
+
// on both the client and the server. `server.tsx` imports `@prairielearn/compiled-assets`,
|
|
5
|
+
// which cannot be bundled for the browser.
|
|
6
|
+
/**
|
|
7
|
+
* Render a non-interactive React component that is embedded within a tagged template literal.
|
|
8
|
+
* This function is intended to be used within a tagged template literal, e.g. html`...`.
|
|
9
|
+
*
|
|
10
|
+
* @param node - Contents to render to HTML.
|
|
11
|
+
* @returns An `HtmlSafeString` containing the rendered HTML.
|
|
12
|
+
*/
|
|
13
|
+
export function renderHtml(node) {
|
|
14
|
+
if (typeof node === 'string') {
|
|
15
|
+
return escapeHtml(new HtmlSafeString([node], []));
|
|
16
|
+
}
|
|
17
|
+
return unsafeHtml(renderToString(node));
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAElD,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAE5E,sFAAsF;AACtF,2FAA2F;AAC3F,2CAA2C;AAE3C;;;;;;GAMG;AACH,MAAM,UAAU,UAAU,CAAC,IAAwB,EAAkB;IACnE,IAAI,OAAO,IAAI,KAAK,QAAQ,EAAE,CAAC;QAC7B,OAAO,UAAU,CAAC,IAAI,cAAc,CAAC,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC;IACpD,CAAC;IAED,OAAO,UAAU,CAAC,cAAc,CAAC,IAAI,CAAC,CAAC,CAAC;AAAA,CACzC","sourcesContent":["import type { ReactNode } from 'react';\nimport { renderToString } from 'react-dom/server';\n\nimport { HtmlSafeString, escapeHtml, unsafeHtml } from '@prairielearn/html';\n\n// These functions are separated from the other utilities so that they can be imported\n// on both the client and the server. `server.tsx` imports `@prairielearn/compiled-assets`,\n// which cannot be bundled for the browser.\n\n/**\n * Render a non-interactive React component that is embedded within a tagged template literal.\n * This function is intended to be used within a tagged template literal, e.g. html`...`.\n *\n * @param node - Contents to render to HTML.\n * @returns An `HtmlSafeString` containing the rendered HTML.\n */\nexport function renderHtml(node: ReactNode | string): HtmlSafeString {\n if (typeof node === 'string') {\n return escapeHtml(new HtmlSafeString([node], []));\n }\n\n return unsafeHtml(renderToString(node));\n}\n"]}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type ReactElement, type ReactNode } from 'react';
|
|
2
|
+
import { type HtmlSafeString } from '@prairielearn/html';
|
|
3
|
+
/**
|
|
4
|
+
* Render an entire React page as an HTML document.
|
|
5
|
+
*
|
|
6
|
+
* @param content - A React node to render to HTML.
|
|
7
|
+
* @returns An HTML string containing the rendered content.
|
|
8
|
+
*/
|
|
9
|
+
export declare function renderHtmlDocument(content: ReactNode): string;
|
|
10
|
+
interface HydrateProps<T> {
|
|
11
|
+
/** The component to hydrate. */
|
|
12
|
+
children: ReactElement<T>;
|
|
13
|
+
/** Optional override for the component's name or displayName. */
|
|
14
|
+
nameOverride?: string;
|
|
15
|
+
/** Whether to apply full height styles. */
|
|
16
|
+
fullHeight?: boolean;
|
|
17
|
+
/** Optional CSS class to apply to the container. */
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A component that renders a React component for client-side hydration.
|
|
22
|
+
* All interactive components will need to be hydrated.
|
|
23
|
+
* This component is intended to be used within a non-interactive React component
|
|
24
|
+
* that will be rendered without hydration through `renderHtml`.
|
|
25
|
+
*/
|
|
26
|
+
export declare function Hydrate<T>({ children, nameOverride, className, fullHeight }: HydrateProps<T>): ReactNode;
|
|
27
|
+
/**
|
|
28
|
+
* Renders a React component for client-side hydration and returns an HTML-safe string.
|
|
29
|
+
* This function is intended to be used within a tagged template literal, e.g. html`...`.
|
|
30
|
+
*
|
|
31
|
+
* @param content - A React node to render to HTML.
|
|
32
|
+
* @returns An `HtmlSafeString` containing the rendered HTML.
|
|
33
|
+
*/
|
|
34
|
+
export declare function hydrateHtml<T>(content: ReactElement<T>, props?: Omit<HydrateProps<T>, 'children'>): HtmlSafeString;
|
|
35
|
+
export {};
|
|
36
|
+
//# sourceMappingURL=server.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.tsx"],"names":[],"mappings":"AACA,OAAO,EAAY,KAAK,YAAY,EAAE,KAAK,SAAS,EAAkB,MAAM,OAAO,CAAC;AAMpF,OAAO,EAAE,KAAK,cAAc,EAAQ,MAAM,oBAAoB,CAAC;AAwB/D;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,SAAS,GAAG,MAAM,CAE7D;AAED,UAAU,YAAY,CAAC,CAAC;IACtB,gCAAgC;IAChC,QAAQ,EAAE,YAAY,CAAC,CAAC,CAAC,CAAC;IAC1B,iEAAiE;IACjE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,2CAA2C;IAC3C,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,oDAAoD;IACpD,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;GAKG;AACH,wBAAgB,OAAO,CAAC,CAAC,EAAE,EACzB,QAAQ,EACR,YAAY,EACZ,SAAS,EACT,UAAkB,EACnB,EAAE,YAAY,CAAC,CAAC,CAAC,GAAG,SAAS,CAqF7B;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,CAAC,EAC3B,OAAO,EAAE,YAAY,CAAC,CAAC,CAAC,EACxB,KAAK,GAAE,IAAI,CAAC,YAAY,CAAC,CAAC,CAAC,EAAE,UAAU,CAAM,GAC5C,cAAc,CAGhB","sourcesContent":["import clsx from 'clsx';\nimport { Fragment, type ReactElement, type ReactNode, isValidElement } from 'react';\nimport { renderToString } from 'react-dom/server';\nimport superjson from 'superjson';\n\nimport { compiledScriptPath, compiledScriptPreloadPaths } from '@prairielearn/compiled-assets';\nimport { AugmentedError } from '@prairielearn/error';\nimport { type HtmlSafeString, html } from '@prairielearn/html';\n\nimport { renderHtml } from './index.js';\n\n// Based on https://pkg.go.dev/encoding/json#HTMLEscape\nconst ENCODE_HTML_RULES: Record<string, string> = {\n '&': '\\\\u0026',\n '>': '\\\\u003e',\n '<': '\\\\u003c',\n '\\u2028': '\\\\u2028',\n '\\u2029': '\\\\u2029',\n};\nconst MATCH_HTML = /[&><\\u2028\\u2029]/g;\n\n/**\n * Escape a value for use in a JSON string that will be rendered in HTML.\n *\n * @param value - The value to escape.\n * @returns A JSON string with HTML-sensitive characters escaped.\n */\nfunction escapeJsonForHtml(value: any): string {\n return superjson.stringify(value).replaceAll(MATCH_HTML, (c) => ENCODE_HTML_RULES[c] || c);\n}\n\n/**\n * Render an entire React page as an HTML document.\n *\n * @param content - A React node to render to HTML.\n * @returns An HTML string containing the rendered content.\n */\nexport function renderHtmlDocument(content: ReactNode): string {\n return `<!doctype html>\\n${renderHtml(content)}`;\n}\n\ninterface HydrateProps<T> {\n /** The component to hydrate. */\n children: ReactElement<T>;\n /** Optional override for the component's name or displayName. */\n nameOverride?: string;\n /** Whether to apply full height styles. */\n fullHeight?: boolean;\n /** Optional CSS class to apply to the container. */\n className?: string;\n}\n\n/**\n * A component that renders a React component for client-side hydration.\n * All interactive components will need to be hydrated.\n * This component is intended to be used within a non-interactive React component\n * that will be rendered without hydration through `renderHtml`.\n */\nexport function Hydrate<T>({\n children,\n nameOverride,\n className,\n fullHeight = false,\n}: HydrateProps<T>): ReactNode {\n if (!isValidElement(children)) {\n throw new Error('<Hydrate> expects a single React component as its child');\n }\n\n if (children.type === Fragment) {\n throw new Error('<Hydrate> does not support fragments');\n }\n\n const { type: Component, props } = children;\n if (typeof Component !== 'function') {\n throw new Error('<Hydrate> expects a React component');\n }\n\n // Note that we don't use `Component.name` here because it can be minified or mangled.\n const componentName = nameOverride ?? (Component as any).displayName;\n if (!componentName) {\n // This is only defined in development, not in production when the function name is minified.\n const componentDevName = Component.name || 'UnknownComponent';\n throw new AugmentedError(\n '<Hydrate> expects a component to have a displayName or nameOverride.',\n {\n info: html`\n <div>\n <p>Make sure to add a displayName to the component:</p>\n <pre><code>export const ${componentDevName} = ...;\n// Add this line:\n${componentDevName}.displayName = '${componentDevName}';</code></pre>\n </div>\n `,\n },\n );\n }\n\n const scriptPath = `esm-bundles/hydrated-components/${componentName}.ts`;\n let compiledScriptSrc = '';\n try {\n compiledScriptSrc = compiledScriptPath(scriptPath);\n } catch (error) {\n throw new AugmentedError(`Could not find script for component \"${componentName}\".`, {\n info: html`\n <div>\n Make sure you create a script at\n <code>esm-bundles/hydrated-components/${componentName}.ts</code> registering the\n component:\n <pre><code>import { registerHydratedComponent } from '@prairielearn/react/hydrated-component';\n\nimport { ${componentName} } from './path/to/component.js';\n\nregisterHydratedComponent(${componentName});</code></pre>\n </div>\n `,\n cause: error,\n });\n }\n const scriptPreloads = compiledScriptPreloadPaths(scriptPath);\n return (\n <Fragment>\n <script type=\"module\" src={compiledScriptSrc} />\n {scriptPreloads.map((preloadPath) => (\n <link key={preloadPath} rel=\"modulepreload\" href={preloadPath} />\n ))}\n <script\n type=\"application/json\"\n // eslint-disable-next-line @eslint-react/dom/no-dangerously-set-innerhtml\n dangerouslySetInnerHTML={{\n __html: escapeJsonForHtml(props),\n }}\n data-component={componentName}\n data-component-props\n />\n <div\n data-component={componentName}\n className={clsx('js-hydrated-component', { 'h-100': fullHeight }, className)}\n // Render the component in an isolated React tree so that it's at the \"root\"\n // position, matching the client-side hydration which also places the component\n // at the root of its own tree. This ensures hooks like `useId()` generate\n // consistent values between server and client.\n // eslint-disable-next-line @eslint-react/dom/no-dangerously-set-innerhtml\n dangerouslySetInnerHTML={{\n __html: renderToString(<Component {...props} />),\n }}\n />\n </Fragment>\n );\n}\n\n/**\n * Renders a React component for client-side hydration and returns an HTML-safe string.\n * This function is intended to be used within a tagged template literal, e.g. html`...`.\n *\n * @param content - A React node to render to HTML.\n * @returns An `HtmlSafeString` containing the rendered HTML.\n */\nexport function hydrateHtml<T>(\n content: ReactElement<T>,\n props: Omit<HydrateProps<T>, 'children'> = {},\n): HtmlSafeString {\n // Useful for adding React components to existing tagged-template pages.\n return renderHtml(<Hydrate {...props}>{content}</Hydrate>);\n}\n"]}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import { Fragment, isValidElement } from 'react';
|
|
4
|
+
import { renderToString } from 'react-dom/server';
|
|
5
|
+
import superjson from 'superjson';
|
|
6
|
+
import { compiledScriptPath, compiledScriptPreloadPaths } from '@prairielearn/compiled-assets';
|
|
7
|
+
import { AugmentedError } from '@prairielearn/error';
|
|
8
|
+
import { html } from '@prairielearn/html';
|
|
9
|
+
import { renderHtml } from './index.js';
|
|
10
|
+
// Based on https://pkg.go.dev/encoding/json#HTMLEscape
|
|
11
|
+
const ENCODE_HTML_RULES = {
|
|
12
|
+
'&': '\\u0026',
|
|
13
|
+
'>': '\\u003e',
|
|
14
|
+
'<': '\\u003c',
|
|
15
|
+
'\u2028': '\\u2028',
|
|
16
|
+
'\u2029': '\\u2029',
|
|
17
|
+
};
|
|
18
|
+
const MATCH_HTML = /[&><\u2028\u2029]/g;
|
|
19
|
+
/**
|
|
20
|
+
* Escape a value for use in a JSON string that will be rendered in HTML.
|
|
21
|
+
*
|
|
22
|
+
* @param value - The value to escape.
|
|
23
|
+
* @returns A JSON string with HTML-sensitive characters escaped.
|
|
24
|
+
*/
|
|
25
|
+
function escapeJsonForHtml(value) {
|
|
26
|
+
return superjson.stringify(value).replaceAll(MATCH_HTML, (c) => ENCODE_HTML_RULES[c] || c);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Render an entire React page as an HTML document.
|
|
30
|
+
*
|
|
31
|
+
* @param content - A React node to render to HTML.
|
|
32
|
+
* @returns An HTML string containing the rendered content.
|
|
33
|
+
*/
|
|
34
|
+
export function renderHtmlDocument(content) {
|
|
35
|
+
return `<!doctype html>\n${renderHtml(content)}`;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* A component that renders a React component for client-side hydration.
|
|
39
|
+
* All interactive components will need to be hydrated.
|
|
40
|
+
* This component is intended to be used within a non-interactive React component
|
|
41
|
+
* that will be rendered without hydration through `renderHtml`.
|
|
42
|
+
*/
|
|
43
|
+
export function Hydrate({ children, nameOverride, className, fullHeight = false, }) {
|
|
44
|
+
if (!isValidElement(children)) {
|
|
45
|
+
throw new Error('<Hydrate> expects a single React component as its child');
|
|
46
|
+
}
|
|
47
|
+
if (children.type === Fragment) {
|
|
48
|
+
throw new Error('<Hydrate> does not support fragments');
|
|
49
|
+
}
|
|
50
|
+
const { type: Component, props } = children;
|
|
51
|
+
if (typeof Component !== 'function') {
|
|
52
|
+
throw new Error('<Hydrate> expects a React component');
|
|
53
|
+
}
|
|
54
|
+
// Note that we don't use `Component.name` here because it can be minified or mangled.
|
|
55
|
+
const componentName = nameOverride ?? Component.displayName;
|
|
56
|
+
if (!componentName) {
|
|
57
|
+
// This is only defined in development, not in production when the function name is minified.
|
|
58
|
+
const componentDevName = Component.name || 'UnknownComponent';
|
|
59
|
+
throw new AugmentedError('<Hydrate> expects a component to have a displayName or nameOverride.', {
|
|
60
|
+
info: html `
|
|
61
|
+
<div>
|
|
62
|
+
<p>Make sure to add a displayName to the component:</p>
|
|
63
|
+
<pre><code>export const ${componentDevName} = ...;
|
|
64
|
+
// Add this line:
|
|
65
|
+
${componentDevName}.displayName = '${componentDevName}';</code></pre>
|
|
66
|
+
</div>
|
|
67
|
+
`,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
const scriptPath = `esm-bundles/hydrated-components/${componentName}.ts`;
|
|
71
|
+
let compiledScriptSrc = '';
|
|
72
|
+
try {
|
|
73
|
+
compiledScriptSrc = compiledScriptPath(scriptPath);
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
throw new AugmentedError(`Could not find script for component "${componentName}".`, {
|
|
77
|
+
info: html `
|
|
78
|
+
<div>
|
|
79
|
+
Make sure you create a script at
|
|
80
|
+
<code>esm-bundles/hydrated-components/${componentName}.ts</code> registering the
|
|
81
|
+
component:
|
|
82
|
+
<pre><code>import { registerHydratedComponent } from '@prairielearn/react/hydrated-component';
|
|
83
|
+
|
|
84
|
+
import { ${componentName} } from './path/to/component.js';
|
|
85
|
+
|
|
86
|
+
registerHydratedComponent(${componentName});</code></pre>
|
|
87
|
+
</div>
|
|
88
|
+
`,
|
|
89
|
+
cause: error,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
const scriptPreloads = compiledScriptPreloadPaths(scriptPath);
|
|
93
|
+
return (_jsxs(Fragment, { children: [
|
|
94
|
+
_jsx("script", { type: "module", src: compiledScriptSrc }), scriptPreloads.map((preloadPath) => (_jsx("link", { rel: "modulepreload", href: preloadPath }, preloadPath))), _jsx("script", { type: "application/json",
|
|
95
|
+
// eslint-disable-next-line @eslint-react/dom/no-dangerously-set-innerhtml
|
|
96
|
+
dangerouslySetInnerHTML: {
|
|
97
|
+
__html: escapeJsonForHtml(props),
|
|
98
|
+
}, "data-component": componentName, "data-component-props": true }), _jsx("div", { "data-component": componentName, className: clsx('js-hydrated-component', { 'h-100': fullHeight }, className),
|
|
99
|
+
// Render the component in an isolated React tree so that it's at the "root"
|
|
100
|
+
// position, matching the client-side hydration which also places the component
|
|
101
|
+
// at the root of its own tree. This ensures hooks like `useId()` generate
|
|
102
|
+
// consistent values between server and client.
|
|
103
|
+
// eslint-disable-next-line @eslint-react/dom/no-dangerously-set-innerhtml
|
|
104
|
+
dangerouslySetInnerHTML: {
|
|
105
|
+
__html: renderToString(_jsx(Component, { ...props })),
|
|
106
|
+
} })] }));
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Renders a React component for client-side hydration and returns an HTML-safe string.
|
|
110
|
+
* This function is intended to be used within a tagged template literal, e.g. html`...`.
|
|
111
|
+
*
|
|
112
|
+
* @param content - A React node to render to HTML.
|
|
113
|
+
* @returns An `HtmlSafeString` containing the rendered HTML.
|
|
114
|
+
*/
|
|
115
|
+
export function hydrateHtml(content, props = {}) {
|
|
116
|
+
// Useful for adding React components to existing tagged-template pages.
|
|
117
|
+
return renderHtml(_jsx(Hydrate, { ...props, children: content }));
|
|
118
|
+
}
|
|
119
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.tsx"],"names":[],"mappings":";AAAA,OAAO,IAAI,MAAM,MAAM,CAAC;AACxB,OAAO,EAAE,QAAQ,EAAqC,cAAc,EAAE,MAAM,OAAO,CAAC;AACpF,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAClD,OAAO,SAAS,MAAM,WAAW,CAAC;AAElC,OAAO,EAAE,kBAAkB,EAAE,0BAA0B,EAAE,MAAM,+BAA+B,CAAC;AAC/F,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EAAuB,IAAI,EAAE,MAAM,oBAAoB,CAAC;AAE/D,OAAO,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAExC,uDAAuD;AACvD,MAAM,iBAAiB,GAA2B;IAChD,GAAG,EAAE,SAAS;IACd,GAAG,EAAE,SAAS;IACd,GAAG,EAAE,SAAS;IACd,QAAQ,EAAE,SAAS;IACnB,QAAQ,EAAE,SAAS;CACpB,CAAC;AACF,MAAM,UAAU,GAAG,oBAAoB,CAAC;AAExC;;;;;GAKG;AACH,SAAS,iBAAiB,CAAC,KAAU,EAAU;IAC7C,OAAO,SAAS,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,UAAU,CAAC,UAAU,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,iBAAiB,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;AAAA,CAC5F;AAED;;;;;GAKG;AACH,MAAM,UAAU,kBAAkB,CAAC,OAAkB,EAAU;IAC7D,OAAO,oBAAoB,UAAU,CAAC,OAAO,CAAC,EAAE,CAAC;AAAA,CAClD;AAaD;;;;;GAKG;AACH,MAAM,UAAU,OAAO,CAAI,EACzB,QAAQ,EACR,YAAY,EACZ,SAAS,EACT,UAAU,GAAG,KAAK,GACF,EAAa;IAC7B,IAAI,CAAC,cAAc,CAAC,QAAQ,CAAC,EAAE,CAAC;QAC9B,MAAM,IAAI,KAAK,CAAC,yDAAyD,CAAC,CAAC;IAC7E,CAAC;IAED,IAAI,QAAQ,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;QAC/B,MAAM,IAAI,KAAK,CAAC,sCAAsC,CAAC,CAAC;IAC1D,CAAC;IAED,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,GAAG,QAAQ,CAAC;IAC5C,IAAI,OAAO,SAAS,KAAK,UAAU,EAAE,CAAC;QACpC,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAC;IACzD,CAAC;IAED,sFAAsF;IACtF,MAAM,aAAa,GAAG,YAAY,IAAK,SAAiB,CAAC,WAAW,CAAC;IACrE,IAAI,CAAC,aAAa,EAAE,CAAC;QACnB,6FAA6F;QAC7F,MAAM,gBAAgB,GAAG,SAAS,CAAC,IAAI,IAAI,kBAAkB,CAAC;QAC9D,MAAM,IAAI,cAAc,CACtB,sEAAsE,EACtE;YACE,IAAI,EAAE,IAAI,CAAA;;;sCAGoB,gBAAgB;;EAEpD,gBAAgB,mBAAmB,gBAAgB;;SAE5C;SACF,CACF,CAAC;IACJ,CAAC;IAED,MAAM,UAAU,GAAG,mCAAmC,aAAa,KAAK,CAAC;IACzE,IAAI,iBAAiB,GAAG,EAAE,CAAC;IAC3B,IAAI,CAAC;QACH,iBAAiB,GAAG,kBAAkB,CAAC,UAAU,CAAC,CAAC;IACrD,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,IAAI,cAAc,CAAC,wCAAwC,aAAa,IAAI,EAAE;YAClF,IAAI,EAAE,IAAI,CAAA;;;kDAGkC,aAAa;;;;WAIpD,aAAa;;4BAEI,aAAa;;OAElC;YACD,KAAK,EAAE,KAAK;SACb,CAAC,CAAC;IACL,CAAC;IACD,MAAM,cAAc,GAAG,0BAA0B,CAAC,UAAU,CAAC,CAAC;IAC9D,OAAO,CACL,MAAC,QAAQ;YACP,iBAAQ,IAAI,EAAC,QAAQ,EAAC,GAAG,EAAE,iBAAiB,GAAI,EAC/C,cAAc,CAAC,GAAG,CAAC,CAAC,WAAW,EAAE,EAAE,CAAC,CACnC,eAAwB,GAAG,EAAC,eAAe,EAAC,IAAI,EAAE,WAAW,IAAlD,WAAW,CAA2C,CAClE,CAAC,EACF,iBACE,IAAI,EAAC,kBAAkB;gBACvB,0EAA0E;gBAC1E,uBAAuB,EAAE;oBACvB,MAAM,EAAE,iBAAiB,CAAC,KAAK,CAAC;iBACjC,oBACe,aAAa,iCAE7B,EACF,gCACkB,aAAa,EAC7B,SAAS,EAAE,IAAI,CAAC,uBAAuB,EAAE,EAAE,OAAO,EAAE,UAAU,EAAE,EAAE,SAAS,CAAC;gBAC5E,4EAA4E;gBAC5E,+EAA+E;gBAC/E,0EAA0E;gBAC1E,+CAA+C;gBAC/C,0EAA0E;gBAC1E,uBAAuB,EAAE;oBACvB,MAAM,EAAE,cAAc,CAAC,KAAC,SAAS,OAAK,KAAK,GAAI,CAAC;iBACjD,GACD,IACO,CACZ,CAAC;AAAA,CACH;AAED;;;;;;GAMG;AACH,MAAM,UAAU,WAAW,CACzB,OAAwB,EACxB,KAAK,GAAsC,EAAE,EAC7B;IAChB,wEAAwE;IACxE,OAAO,UAAU,CAAC,KAAC,OAAO,OAAK,KAAK,YAAG,OAAO,GAAW,CAAC,CAAC;AAAA,CAC5D","sourcesContent":["import clsx from 'clsx';\nimport { Fragment, type ReactElement, type ReactNode, isValidElement } from 'react';\nimport { renderToString } from 'react-dom/server';\nimport superjson from 'superjson';\n\nimport { compiledScriptPath, compiledScriptPreloadPaths } from '@prairielearn/compiled-assets';\nimport { AugmentedError } from '@prairielearn/error';\nimport { type HtmlSafeString, html } from '@prairielearn/html';\n\nimport { renderHtml } from './index.js';\n\n// Based on https://pkg.go.dev/encoding/json#HTMLEscape\nconst ENCODE_HTML_RULES: Record<string, string> = {\n '&': '\\\\u0026',\n '>': '\\\\u003e',\n '<': '\\\\u003c',\n '\\u2028': '\\\\u2028',\n '\\u2029': '\\\\u2029',\n};\nconst MATCH_HTML = /[&><\\u2028\\u2029]/g;\n\n/**\n * Escape a value for use in a JSON string that will be rendered in HTML.\n *\n * @param value - The value to escape.\n * @returns A JSON string with HTML-sensitive characters escaped.\n */\nfunction escapeJsonForHtml(value: any): string {\n return superjson.stringify(value).replaceAll(MATCH_HTML, (c) => ENCODE_HTML_RULES[c] || c);\n}\n\n/**\n * Render an entire React page as an HTML document.\n *\n * @param content - A React node to render to HTML.\n * @returns An HTML string containing the rendered content.\n */\nexport function renderHtmlDocument(content: ReactNode): string {\n return `<!doctype html>\\n${renderHtml(content)}`;\n}\n\ninterface HydrateProps<T> {\n /** The component to hydrate. */\n children: ReactElement<T>;\n /** Optional override for the component's name or displayName. */\n nameOverride?: string;\n /** Whether to apply full height styles. */\n fullHeight?: boolean;\n /** Optional CSS class to apply to the container. */\n className?: string;\n}\n\n/**\n * A component that renders a React component for client-side hydration.\n * All interactive components will need to be hydrated.\n * This component is intended to be used within a non-interactive React component\n * that will be rendered without hydration through `renderHtml`.\n */\nexport function Hydrate<T>({\n children,\n nameOverride,\n className,\n fullHeight = false,\n}: HydrateProps<T>): ReactNode {\n if (!isValidElement(children)) {\n throw new Error('<Hydrate> expects a single React component as its child');\n }\n\n if (children.type === Fragment) {\n throw new Error('<Hydrate> does not support fragments');\n }\n\n const { type: Component, props } = children;\n if (typeof Component !== 'function') {\n throw new Error('<Hydrate> expects a React component');\n }\n\n // Note that we don't use `Component.name` here because it can be minified or mangled.\n const componentName = nameOverride ?? (Component as any).displayName;\n if (!componentName) {\n // This is only defined in development, not in production when the function name is minified.\n const componentDevName = Component.name || 'UnknownComponent';\n throw new AugmentedError(\n '<Hydrate> expects a component to have a displayName or nameOverride.',\n {\n info: html`\n <div>\n <p>Make sure to add a displayName to the component:</p>\n <pre><code>export const ${componentDevName} = ...;\n// Add this line:\n${componentDevName}.displayName = '${componentDevName}';</code></pre>\n </div>\n `,\n },\n );\n }\n\n const scriptPath = `esm-bundles/hydrated-components/${componentName}.ts`;\n let compiledScriptSrc = '';\n try {\n compiledScriptSrc = compiledScriptPath(scriptPath);\n } catch (error) {\n throw new AugmentedError(`Could not find script for component \"${componentName}\".`, {\n info: html`\n <div>\n Make sure you create a script at\n <code>esm-bundles/hydrated-components/${componentName}.ts</code> registering the\n component:\n <pre><code>import { registerHydratedComponent } from '@prairielearn/react/hydrated-component';\n\nimport { ${componentName} } from './path/to/component.js';\n\nregisterHydratedComponent(${componentName});</code></pre>\n </div>\n `,\n cause: error,\n });\n }\n const scriptPreloads = compiledScriptPreloadPaths(scriptPath);\n return (\n <Fragment>\n <script type=\"module\" src={compiledScriptSrc} />\n {scriptPreloads.map((preloadPath) => (\n <link key={preloadPath} rel=\"modulepreload\" href={preloadPath} />\n ))}\n <script\n type=\"application/json\"\n // eslint-disable-next-line @eslint-react/dom/no-dangerously-set-innerhtml\n dangerouslySetInnerHTML={{\n __html: escapeJsonForHtml(props),\n }}\n data-component={componentName}\n data-component-props\n />\n <div\n data-component={componentName}\n className={clsx('js-hydrated-component', { 'h-100': fullHeight }, className)}\n // Render the component in an isolated React tree so that it's at the \"root\"\n // position, matching the client-side hydration which also places the component\n // at the root of its own tree. This ensures hooks like `useId()` generate\n // consistent values between server and client.\n // eslint-disable-next-line @eslint-react/dom/no-dangerously-set-innerhtml\n dangerouslySetInnerHTML={{\n __html: renderToString(<Component {...props} />),\n }}\n />\n </Fragment>\n );\n}\n\n/**\n * Renders a React component for client-side hydration and returns an HTML-safe string.\n * This function is intended to be used within a tagged template literal, e.g. html`...`.\n *\n * @param content - A React node to render to HTML.\n * @returns An `HtmlSafeString` containing the rendered HTML.\n */\nexport function hydrateHtml<T>(\n content: ReactElement<T>,\n props: Omit<HydrateProps<T>, 'children'> = {},\n): HtmlSafeString {\n // Useful for adding React components to existing tagged-template pages.\n return renderHtml(<Hydrate {...props}>{content}</Hydrate>);\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,10 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prairielearn/react",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/PrairieLearn/PrairieLearn.git",
|
|
8
|
+
"directory": "packages/react"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./dist/index.js",
|
|
12
|
+
"./server": "./dist/server.js",
|
|
13
|
+
"./hydrated-component": "./dist/hydrated-component/index.js"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsgo",
|
|
17
|
+
"dev": "tsgo --watch --preserveWatchOutput"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@prairielearn/browser-utils": "^2.6.1",
|
|
21
|
+
"@prairielearn/compiled-assets": "^3.3.3",
|
|
22
|
+
"@prairielearn/error": "^2.0.24",
|
|
23
|
+
"@prairielearn/html": "^4.0.24",
|
|
24
|
+
"@prairielearn/utils": "^2.0.5",
|
|
25
|
+
"@types/react": "^19.2.8",
|
|
26
|
+
"@types/react-dom": "^19.2.3",
|
|
27
|
+
"clsx": "^2.1.1",
|
|
28
|
+
"react": "^19.2.3",
|
|
29
|
+
"react-dom": "^19.2.3",
|
|
30
|
+
"selector-observer": "^2.1.6",
|
|
31
|
+
"superjson": "^2.2.6"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@prairielearn/tsconfig": "^0.0.0",
|
|
35
|
+
"@types/node": "^22.19.6",
|
|
36
|
+
"@typescript/native-preview": "^7.0.0-dev.20260106.1",
|
|
37
|
+
"typescript": "^5.9.3"
|
|
38
|
+
}
|
|
10
39
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ComponentType } from 'react';
|
|
2
|
+
import { hydrateRoot } from 'react-dom/client';
|
|
3
|
+
import { observe } from 'selector-observer';
|
|
4
|
+
import superjson from 'superjson';
|
|
5
|
+
|
|
6
|
+
import { onDocumentReady } from '@prairielearn/browser-utils';
|
|
7
|
+
|
|
8
|
+
import { HydratedComponentsRegistry } from './registry.js';
|
|
9
|
+
|
|
10
|
+
// This file, if imported, will register a selector observer that will hydrate
|
|
11
|
+
// registered components on the client.
|
|
12
|
+
|
|
13
|
+
const registry = new HydratedComponentsRegistry();
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Registers a React component for client-side hydration. The component should have a
|
|
17
|
+
* `displayName` property. If it's missing, or the name of the component bundle differs,
|
|
18
|
+
* you can provide a `nameOverride`.
|
|
19
|
+
*/
|
|
20
|
+
export function registerHydratedComponent(component: ComponentType<any>, nameOverride?: string) {
|
|
21
|
+
// Each React component that will be hydrated on the page must be registered.
|
|
22
|
+
// Note that we don't try to use `component.name` since it can be minified or mangled.
|
|
23
|
+
const id = nameOverride ?? component.displayName;
|
|
24
|
+
if (!id) {
|
|
25
|
+
throw new Error('React fragment must have a displayName or nameOverride');
|
|
26
|
+
}
|
|
27
|
+
registry.setComponent(id, component);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
onDocumentReady(() => {
|
|
31
|
+
observe('.js-hydrated-component', {
|
|
32
|
+
async add(el) {
|
|
33
|
+
const componentName = el.getAttribute('data-component');
|
|
34
|
+
if (!componentName) {
|
|
35
|
+
throw new Error('js-hydrated-component element must have a data-component attribute');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// If you forget to register a component with `registerHydratedComponent`, this is going to hang.
|
|
39
|
+
const Component = await registry.getComponent(componentName);
|
|
40
|
+
|
|
41
|
+
const dataElement = el.previousElementSibling;
|
|
42
|
+
if (!dataElement) throw new Error('No data element found');
|
|
43
|
+
if (!dataElement.hasAttribute('data-component-props')) {
|
|
44
|
+
throw new Error('Data element is missing data-component-props attribute');
|
|
45
|
+
}
|
|
46
|
+
if (!dataElement.textContent) throw new Error('Data element has no content');
|
|
47
|
+
const data: object = superjson.parse(dataElement.textContent);
|
|
48
|
+
|
|
49
|
+
hydrateRoot(el, <Component {...data} />);
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ComponentType } from 'react';
|
|
2
|
+
|
|
3
|
+
import { type PromiseWithResolvers, withResolvers } from '@prairielearn/utils';
|
|
4
|
+
|
|
5
|
+
type AugmentedPromiseWithResolvers<T> = PromiseWithResolvers<T> & {
|
|
6
|
+
resolved: boolean;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export class HydratedComponentsRegistry {
|
|
10
|
+
private components: Record<string, AugmentedPromiseWithResolvers<ComponentType<any>>> = {};
|
|
11
|
+
|
|
12
|
+
setComponent(id: string, component: ComponentType<any>) {
|
|
13
|
+
if (this.components[id]?.resolved) {
|
|
14
|
+
throw new Error(`React fragment with id ${id} already resolved`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (!this.components[id]) {
|
|
18
|
+
this.components[id] = { ...withResolvers<ComponentType<any>>(), resolved: false };
|
|
19
|
+
}
|
|
20
|
+
this.components[id].resolve(component);
|
|
21
|
+
this.components[id].resolved = true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
getComponent(id: string): Promise<ComponentType<any>> {
|
|
25
|
+
if (!this.components[id]) {
|
|
26
|
+
// This promise will be resolved later when the component is registered via `setFragment`.
|
|
27
|
+
this.components[id] = { ...withResolvers<ComponentType<any>>(), resolved: false };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return this.components[id].promise;
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { renderToString } from 'react-dom/server';
|
|
3
|
+
|
|
4
|
+
import { HtmlSafeString, escapeHtml, unsafeHtml } from '@prairielearn/html';
|
|
5
|
+
|
|
6
|
+
// These functions are separated from the other utilities so that they can be imported
|
|
7
|
+
// on both the client and the server. `server.tsx` imports `@prairielearn/compiled-assets`,
|
|
8
|
+
// which cannot be bundled for the browser.
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Render a non-interactive React component that is embedded within a tagged template literal.
|
|
12
|
+
* This function is intended to be used within a tagged template literal, e.g. html`...`.
|
|
13
|
+
*
|
|
14
|
+
* @param node - Contents to render to HTML.
|
|
15
|
+
* @returns An `HtmlSafeString` containing the rendered HTML.
|
|
16
|
+
*/
|
|
17
|
+
export function renderHtml(node: ReactNode | string): HtmlSafeString {
|
|
18
|
+
if (typeof node === 'string') {
|
|
19
|
+
return escapeHtml(new HtmlSafeString([node], []));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return unsafeHtml(renderToString(node));
|
|
23
|
+
}
|
package/src/server.tsx
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import clsx from 'clsx';
|
|
2
|
+
import { Fragment, type ReactElement, type ReactNode, isValidElement } from 'react';
|
|
3
|
+
import { renderToString } from 'react-dom/server';
|
|
4
|
+
import superjson from 'superjson';
|
|
5
|
+
|
|
6
|
+
import { compiledScriptPath, compiledScriptPreloadPaths } from '@prairielearn/compiled-assets';
|
|
7
|
+
import { AugmentedError } from '@prairielearn/error';
|
|
8
|
+
import { type HtmlSafeString, html } from '@prairielearn/html';
|
|
9
|
+
|
|
10
|
+
import { renderHtml } from './index.js';
|
|
11
|
+
|
|
12
|
+
// Based on https://pkg.go.dev/encoding/json#HTMLEscape
|
|
13
|
+
const ENCODE_HTML_RULES: Record<string, string> = {
|
|
14
|
+
'&': '\\u0026',
|
|
15
|
+
'>': '\\u003e',
|
|
16
|
+
'<': '\\u003c',
|
|
17
|
+
'\u2028': '\\u2028',
|
|
18
|
+
'\u2029': '\\u2029',
|
|
19
|
+
};
|
|
20
|
+
const MATCH_HTML = /[&><\u2028\u2029]/g;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Escape a value for use in a JSON string that will be rendered in HTML.
|
|
24
|
+
*
|
|
25
|
+
* @param value - The value to escape.
|
|
26
|
+
* @returns A JSON string with HTML-sensitive characters escaped.
|
|
27
|
+
*/
|
|
28
|
+
function escapeJsonForHtml(value: any): string {
|
|
29
|
+
return superjson.stringify(value).replaceAll(MATCH_HTML, (c) => ENCODE_HTML_RULES[c] || c);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Render an entire React page as an HTML document.
|
|
34
|
+
*
|
|
35
|
+
* @param content - A React node to render to HTML.
|
|
36
|
+
* @returns An HTML string containing the rendered content.
|
|
37
|
+
*/
|
|
38
|
+
export function renderHtmlDocument(content: ReactNode): string {
|
|
39
|
+
return `<!doctype html>\n${renderHtml(content)}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface HydrateProps<T> {
|
|
43
|
+
/** The component to hydrate. */
|
|
44
|
+
children: ReactElement<T>;
|
|
45
|
+
/** Optional override for the component's name or displayName. */
|
|
46
|
+
nameOverride?: string;
|
|
47
|
+
/** Whether to apply full height styles. */
|
|
48
|
+
fullHeight?: boolean;
|
|
49
|
+
/** Optional CSS class to apply to the container. */
|
|
50
|
+
className?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A component that renders a React component for client-side hydration.
|
|
55
|
+
* All interactive components will need to be hydrated.
|
|
56
|
+
* This component is intended to be used within a non-interactive React component
|
|
57
|
+
* that will be rendered without hydration through `renderHtml`.
|
|
58
|
+
*/
|
|
59
|
+
export function Hydrate<T>({
|
|
60
|
+
children,
|
|
61
|
+
nameOverride,
|
|
62
|
+
className,
|
|
63
|
+
fullHeight = false,
|
|
64
|
+
}: HydrateProps<T>): ReactNode {
|
|
65
|
+
if (!isValidElement(children)) {
|
|
66
|
+
throw new Error('<Hydrate> expects a single React component as its child');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (children.type === Fragment) {
|
|
70
|
+
throw new Error('<Hydrate> does not support fragments');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const { type: Component, props } = children;
|
|
74
|
+
if (typeof Component !== 'function') {
|
|
75
|
+
throw new Error('<Hydrate> expects a React component');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Note that we don't use `Component.name` here because it can be minified or mangled.
|
|
79
|
+
const componentName = nameOverride ?? (Component as any).displayName;
|
|
80
|
+
if (!componentName) {
|
|
81
|
+
// This is only defined in development, not in production when the function name is minified.
|
|
82
|
+
const componentDevName = Component.name || 'UnknownComponent';
|
|
83
|
+
throw new AugmentedError(
|
|
84
|
+
'<Hydrate> expects a component to have a displayName or nameOverride.',
|
|
85
|
+
{
|
|
86
|
+
info: html`
|
|
87
|
+
<div>
|
|
88
|
+
<p>Make sure to add a displayName to the component:</p>
|
|
89
|
+
<pre><code>export const ${componentDevName} = ...;
|
|
90
|
+
// Add this line:
|
|
91
|
+
${componentDevName}.displayName = '${componentDevName}';</code></pre>
|
|
92
|
+
</div>
|
|
93
|
+
`,
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const scriptPath = `esm-bundles/hydrated-components/${componentName}.ts`;
|
|
99
|
+
let compiledScriptSrc = '';
|
|
100
|
+
try {
|
|
101
|
+
compiledScriptSrc = compiledScriptPath(scriptPath);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
throw new AugmentedError(`Could not find script for component "${componentName}".`, {
|
|
104
|
+
info: html`
|
|
105
|
+
<div>
|
|
106
|
+
Make sure you create a script at
|
|
107
|
+
<code>esm-bundles/hydrated-components/${componentName}.ts</code> registering the
|
|
108
|
+
component:
|
|
109
|
+
<pre><code>import { registerHydratedComponent } from '@prairielearn/react/hydrated-component';
|
|
110
|
+
|
|
111
|
+
import { ${componentName} } from './path/to/component.js';
|
|
112
|
+
|
|
113
|
+
registerHydratedComponent(${componentName});</code></pre>
|
|
114
|
+
</div>
|
|
115
|
+
`,
|
|
116
|
+
cause: error,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
const scriptPreloads = compiledScriptPreloadPaths(scriptPath);
|
|
120
|
+
return (
|
|
121
|
+
<Fragment>
|
|
122
|
+
<script type="module" src={compiledScriptSrc} />
|
|
123
|
+
{scriptPreloads.map((preloadPath) => (
|
|
124
|
+
<link key={preloadPath} rel="modulepreload" href={preloadPath} />
|
|
125
|
+
))}
|
|
126
|
+
<script
|
|
127
|
+
type="application/json"
|
|
128
|
+
// eslint-disable-next-line @eslint-react/dom/no-dangerously-set-innerhtml
|
|
129
|
+
dangerouslySetInnerHTML={{
|
|
130
|
+
__html: escapeJsonForHtml(props),
|
|
131
|
+
}}
|
|
132
|
+
data-component={componentName}
|
|
133
|
+
data-component-props
|
|
134
|
+
/>
|
|
135
|
+
<div
|
|
136
|
+
data-component={componentName}
|
|
137
|
+
className={clsx('js-hydrated-component', { 'h-100': fullHeight }, className)}
|
|
138
|
+
// Render the component in an isolated React tree so that it's at the "root"
|
|
139
|
+
// position, matching the client-side hydration which also places the component
|
|
140
|
+
// at the root of its own tree. This ensures hooks like `useId()` generate
|
|
141
|
+
// consistent values between server and client.
|
|
142
|
+
// eslint-disable-next-line @eslint-react/dom/no-dangerously-set-innerhtml
|
|
143
|
+
dangerouslySetInnerHTML={{
|
|
144
|
+
__html: renderToString(<Component {...props} />),
|
|
145
|
+
}}
|
|
146
|
+
/>
|
|
147
|
+
</Fragment>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Renders a React component for client-side hydration and returns an HTML-safe string.
|
|
153
|
+
* This function is intended to be used within a tagged template literal, e.g. html`...`.
|
|
154
|
+
*
|
|
155
|
+
* @param content - A React node to render to HTML.
|
|
156
|
+
* @returns An `HtmlSafeString` containing the rendered HTML.
|
|
157
|
+
*/
|
|
158
|
+
export function hydrateHtml<T>(
|
|
159
|
+
content: ReactElement<T>,
|
|
160
|
+
props: Omit<HydrateProps<T>, 'children'> = {},
|
|
161
|
+
): HtmlSafeString {
|
|
162
|
+
// Useful for adding React components to existing tagged-template pages.
|
|
163
|
+
return renderHtml(<Hydrate {...props}>{content}</Hydrate>);
|
|
164
|
+
}
|