Skip to content

Commit f07cde8

Browse files
authored
fix: SSR render prefetched React embeds (#8)
## Summary - render pre-fetched `MemoEmbed` and `MemoEmbedList` markup during SSR/SSG instead of waiting for hydration - replace imperative `innerHTML` injection with direct render output and memoized HTML generation - add server-render regression tests and document the SSR behavior ## Testing - pnpm validate <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Server-renders pre-fetched `MemoEmbed` and `MemoEmbedList` in `@memos-embed/react` so SSR/SSG pages include full embed HTML without waiting for hydration. Also replaces `innerHTML` injection with direct render output for safer, faster markup. - **Bug Fixes** - SSR/SSG now outputs full embed HTML when `memo`/`memos` props are provided. - Removed imperative `innerHTML`; use memoized HTML and a small `renderHtml` helper. - Added SSR regression tests and updated docs/READMEs; published a patch changeset. <sup>Written for commit a121dfc. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. -->
1 parent 3b6f8e3 commit f07cde8

5 files changed

Lines changed: 116 additions & 46 deletions

File tree

.changeset/slow-pandas-help.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@memos-embed/react": patch
3+
---
4+
5+
Render pre-fetched `MemoEmbed` and `MemoEmbedList` markup during SSR/SSG instead of waiting for client-side hydration.

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,8 @@ const memo = await fetchMemo({
150150
<MemoEmbed memo={memo} />
151151
```
152152

153+
When you pass a pre-fetched `memo`, the React component now renders the full embed HTML during SSR/SSG instead of waiting for hydration.
154+
153155
### React roundup component
154156
```tsx
155157
import { MemoEmbedList } from '@memos-embed/react'
@@ -208,7 +210,7 @@ const [heroMemo, roundupMemos] = await Promise.all([
208210
</MemoClientProvider>
209211
```
210212

211-
Passing `memo` or `memos` while a `MemoClientProvider` is active primes the shared client cache, so later embeds for the same ids can reuse already-fetched data.
213+
Passing `memo` or `memos` while a `MemoClientProvider` is active primes the shared client cache, so later embeds for the same ids can reuse already-fetched data. Those pre-fetched props also render immediately in the initial HTML response for SSR/SSG pages.
212214

213215
### Web Component
214216
```html

packages/memos-embed-react/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ const [heroMemo, roundupMemos] = await Promise.all([
9292

9393
Passing `memo` or `memos` while a `MemoClientProvider` is active primes the shared client cache, so later embeds for the same memo ids can reuse that data.
9494

95+
When you pass pre-fetched `memo` or `memos`, the component now renders full embed markup during SSR/SSG instead of waiting for client-side hydration.
96+
9597
## Pre-fetched usage
9698
```tsx
9799
import { fetchMemo } from 'memos-embed'
@@ -105,6 +107,8 @@ const memo = await fetchMemo({
105107
<MemoEmbed memo={memo} className="my-8" />
106108
```
107109

110+
This path is ideal for MDX, Next.js, Astro, and other SSR setups because the rendered memo HTML is included in the initial response.
111+
108112
## Styling with your blog theme
109113
```tsx
110114
import { extendTheme } from 'memos-embed'

packages/memos-embed-react/src/__tests__/index.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { act, createElement } from "react";
22
import { createRoot } from "react-dom/client";
3+
import { renderToStaticMarkup } from "react-dom/server";
34
import { describe, expect, it, vi } from "vitest";
45
import * as memosEmbed from "memos-embed";
56
import {
@@ -138,6 +139,27 @@ describe("@memos-embed/react", () => {
138139
renderSnippetSpy.mockRestore();
139140
});
140141

142+
it("server-renders a provided memo immediately for prefetched data", () => {
143+
const html = renderToStaticMarkup(
144+
createElement(MemoEmbed, {
145+
memo: {
146+
id: "ssr-1",
147+
name: "memos/ssr-1",
148+
content: "SSR memo",
149+
tags: [],
150+
attachments: [],
151+
reactions: [],
152+
},
153+
className: "memo-ssr",
154+
includeStyles: false,
155+
}),
156+
);
157+
158+
expect(html).toContain('class="memo-ssr"');
159+
expect(html).toContain("SSR memo");
160+
expect(html).not.toContain("Loading memo");
161+
});
162+
141163
it("renders memo roundups through shared list helpers", async () => {
142164
const customFetcher = vi.fn<typeof fetch>();
143165
const memos = [
@@ -294,6 +316,40 @@ describe("@memos-embed/react", () => {
294316
coreFetchMemosSpy.mockRestore();
295317
});
296318

319+
it("server-renders provided memo lists immediately for prefetched data", () => {
320+
const html = renderToStaticMarkup(
321+
createElement(MemoEmbedList, {
322+
memos: [
323+
{
324+
id: "ssr-1",
325+
name: "memos/ssr-1",
326+
content: "SSR one",
327+
tags: [],
328+
attachments: [],
329+
reactions: [],
330+
},
331+
{
332+
id: "ssr-2",
333+
name: "memos/ssr-2",
334+
content: "SSR two",
335+
tags: [],
336+
attachments: [],
337+
reactions: [],
338+
},
339+
],
340+
className: "memo-list-ssr",
341+
includeStyles: false,
342+
layout: "grid",
343+
}),
344+
);
345+
346+
expect(html).toContain('class="memo-list-ssr"');
347+
expect(html).toContain("SSR one");
348+
expect(html).toContain("SSR two");
349+
expect(html).toContain("memos-embed-list--grid");
350+
expect(html).not.toContain("Loading memos");
351+
});
352+
297353
it("primes both single and list caches when provider receives prefetched data", async () => {
298354
const memo = {
299355
id: "1",

packages/memos-embed-react/src/index.tsx

Lines changed: 48 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,9 @@ import {
44
createContext,
55
type CSSProperties,
66
type ReactNode,
7-
type RefObject,
87
useContext,
98
useEffect,
109
useMemo,
11-
useRef,
1210
useState,
1311
} from "react";
1412
import type {
@@ -108,26 +106,39 @@ export const MemoClientProvider = ({
108106
export const useMemoClient = (client?: MemoClient) =>
109107
client ?? useContext(MemoClientContext);
110108

111-
const renderState = ({
112-
message,
109+
const renderHtml = ({
110+
html,
113111
className,
114112
style,
115-
includeStyles,
116113
}: {
117-
message: string;
114+
html: string;
118115
className?: string;
119116
style?: CSSProperties;
120-
includeStyles?: boolean;
121117
}) => (
122118
<div
123119
className={className}
124120
style={style}
125-
dangerouslySetInnerHTML={{
126-
__html: renderMemoStateHtmlSnippet(message, { includeStyles }),
127-
}}
121+
dangerouslySetInnerHTML={{ __html: html }}
128122
/>
129123
);
130124

125+
const renderState = ({
126+
message,
127+
className,
128+
style,
129+
includeStyles,
130+
}: {
131+
message: string;
132+
className?: string;
133+
style?: CSSProperties;
134+
includeStyles?: boolean;
135+
}) =>
136+
renderHtml({
137+
html: renderMemoStateHtmlSnippet(message, { includeStyles }),
138+
className,
139+
style,
140+
});
141+
131142
const useEmbedHtmlOptions = ({
132143
theme,
133144
density,
@@ -228,22 +239,6 @@ const useMemoListHtmlOptions = ({
228239
);
229240
};
230241

231-
const useInjectedHtml = ({
232-
containerRef,
233-
html,
234-
}: {
235-
containerRef: RefObject<HTMLDivElement | null>;
236-
html: string | null;
237-
}) => {
238-
useEffect(() => {
239-
if (!html || !containerRef.current) {
240-
return;
241-
}
242-
243-
containerRef.current.innerHTML = html;
244-
}, [containerRef, html]);
245-
};
246-
247242
export const MemoEmbed = ({
248243
memo: providedMemo,
249244
memoId,
@@ -267,7 +262,6 @@ export const MemoEmbed = ({
267262
}: MemoEmbedProps) => {
268263
const [fetchedMemo, setFetchedMemo] = useState<Memo | null>(null);
269264
const [error, setError] = useState<Error | null>(null);
270-
const containerRef = useRef<HTMLDivElement | null>(null);
271265
const memoClient = useMemoClient(client);
272266
const htmlOptions = useEmbedHtmlOptions({
273267
theme,
@@ -283,9 +277,13 @@ export const MemoEmbed = ({
283277

284278
const resolvedMemo = providedMemo ?? fetchedMemo;
285279
const canFetch = !providedMemo && Boolean(baseUrl) && Boolean(memoId);
286-
const html = resolvedMemo
287-
? renderMemoHtmlSnippet(resolvedMemo, htmlOptions)
288-
: null;
280+
const html = useMemo(
281+
() =>
282+
resolvedMemo
283+
? renderMemoHtmlSnippet(resolvedMemo, htmlOptions)
284+
: null,
285+
[resolvedMemo, htmlOptions],
286+
);
289287

290288
useEffect(() => {
291289
if (!providedMemo) {
@@ -380,8 +378,6 @@ export const MemoEmbed = ({
380378
memoClient,
381379
]);
382380

383-
useInjectedHtml({ containerRef, html });
384-
385381
if (!providedMemo && (!baseUrl || !memoId)) {
386382
return renderState({
387383
message: "baseUrl and memoId are required when memo is not provided.",
@@ -400,7 +396,7 @@ export const MemoEmbed = ({
400396
});
401397
}
402398

403-
if (!resolvedMemo) {
399+
if (!resolvedMemo || !html) {
404400
return renderState({
405401
message: "Loading memo…",
406402
className,
@@ -409,7 +405,11 @@ export const MemoEmbed = ({
409405
});
410406
}
411407

412-
return <div className={className} style={style} ref={containerRef} />;
408+
return renderHtml({
409+
html,
410+
className,
411+
style,
412+
});
413413
};
414414

415415
export const MemoEmbedList = ({
@@ -437,7 +437,6 @@ export const MemoEmbedList = ({
437437
}: MemoEmbedListProps) => {
438438
const [fetchedMemos, setFetchedMemos] = useState<Memo[] | null>(null);
439439
const [error, setError] = useState<Error | null>(null);
440-
const containerRef = useRef<HTMLDivElement | null>(null);
441440
const memoClient = useMemoClient(client);
442441
const memoIdsKey = memoIds.join("\u001f");
443442
const stableMemoIds = useMemo(() => Array.from(memoIds), [memoIdsKey]);
@@ -455,13 +454,15 @@ export const MemoEmbedList = ({
455454
gap,
456455
});
457456

458-
const resolvedMemos = providedMemos
459-
? Array.from(providedMemos)
460-
: fetchedMemos;
457+
const resolvedMemos = providedMemos ?? fetchedMemos;
461458
const canFetch = !providedMemos && Boolean(baseUrl) && stableMemoIds.length > 0;
462-
const html = resolvedMemos
463-
? renderMemoListHtmlSnippet(resolvedMemos, htmlOptions)
464-
: null;
459+
const html = useMemo(
460+
() =>
461+
resolvedMemos
462+
? renderMemoListHtmlSnippet(resolvedMemos, htmlOptions)
463+
: null,
464+
[resolvedMemos, htmlOptions],
465+
);
465466

466467
useEffect(() => {
467468
if (!providedMemos) {
@@ -556,8 +557,6 @@ export const MemoEmbedList = ({
556557
memoClient,
557558
]);
558559

559-
useInjectedHtml({ containerRef, html });
560-
561560
if (!providedMemos && !baseUrl) {
562561
return renderState({
563562
message: "baseUrl is required when memos are not provided.",
@@ -585,7 +584,7 @@ export const MemoEmbedList = ({
585584
});
586585
}
587586

588-
if (!resolvedMemos) {
587+
if (!resolvedMemos || !html) {
589588
return renderState({
590589
message: "Loading memos…",
591590
className,
@@ -598,5 +597,9 @@ export const MemoEmbedList = ({
598597
return null;
599598
}
600599

601-
return <div className={className} style={style} ref={containerRef} />;
600+
return renderHtml({
601+
html,
602+
className,
603+
style,
604+
});
602605
};

0 commit comments

Comments
 (0)