@prairielearn/react 1.0.0 → 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/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.
@@ -1 +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;AAKpF,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,CA+E7B;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 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 >\n <Component {...props} />\n </div>\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"]}
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 CHANGED
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import clsx from 'clsx';
3
3
  import { Fragment, isValidElement } from 'react';
4
+ import { renderToString } from 'react-dom/server';
4
5
  import superjson from 'superjson';
5
6
  import { compiledScriptPath, compiledScriptPreloadPaths } from '@prairielearn/compiled-assets';
6
7
  import { AugmentedError } from '@prairielearn/error';
@@ -94,8 +95,15 @@ registerHydratedComponent(${componentName});</code></pre>
94
95
  // eslint-disable-next-line @eslint-react/dom/no-dangerously-set-innerhtml
95
96
  dangerouslySetInnerHTML: {
96
97
  __html: escapeJsonForHtml(props),
97
- }, "data-component": componentName, "data-component-props": true }), _jsx("div", { "data-component": componentName, className: clsx('js-hydrated-component', { 'h-100': fullHeight }, className), children: _jsx(Component, { ...props }) })
98
- ] }));
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
+ } })] }));
99
107
  }
100
108
  /**
101
109
  * Renders a React component for client-side hydration and returns an HTML-safe string.
@@ -1 +1 @@
1
- {"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.tsx"],"names":[],"mappings":"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","sourcesContent":["import clsx from 'clsx';\nimport { Fragment, type ReactElement, type ReactNode, isValidElement } from 'react';\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 >\n <Component {...props} />\n </div>\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"]}
1
+ {"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.tsx"],"names":[],"mappings":"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","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,6 +1,6 @@
1
1
  {
2
2
  "name": "@prairielearn/react",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "devDependencies": {
34
34
  "@prairielearn/tsconfig": "^0.0.0",
35
- "@types/node": "^22.19.5",
35
+ "@types/node": "^22.19.6",
36
36
  "@typescript/native-preview": "^7.0.0-dev.20260106.1",
37
37
  "typescript": "^5.9.3"
38
38
  }
package/src/server.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import clsx from 'clsx';
2
2
  import { Fragment, type ReactElement, type ReactNode, isValidElement } from 'react';
3
+ import { renderToString } from 'react-dom/server';
3
4
  import superjson from 'superjson';
4
5
 
5
6
  import { compiledScriptPath, compiledScriptPreloadPaths } from '@prairielearn/compiled-assets';
@@ -134,9 +135,15 @@ registerHydratedComponent(${componentName});</code></pre>
134
135
  <div
135
136
  data-component={componentName}
136
137
  className={clsx('js-hydrated-component', { 'h-100': fullHeight }, className)}
137
- >
138
- <Component {...props} />
139
- </div>
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
+ />
140
147
  </Fragment>
141
148
  );
142
149
  }