Skip to content

Commit 15dd88c

Browse files
authored
fix(chat): portal through one hydration-safe Portal (CHAT-50) (#97)
* fix(chat): portal through one hydration-safe Portal (CHAT-50) * chore(changeset): one-line summaries for the portal and nanoid changes
1 parent 9c48204 commit 15dd88c

7 files changed

Lines changed: 88 additions & 20 deletions

File tree

‎.changeset/drop-nanoid.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"@intentface/chat": patch
33
---
44

5-
Drop the `nanoid` dependency. The default attachment `convert` now uses the file's blob URL as its id, so `@floating-ui/dom` is the package's only runtime dependency. Ids are still unique per item, including the same file added twice; nothing in the package parses them, and a custom `convert` can still supply its own.
5+
- Remove the `nanoid` dependency; attachment ids are now the file's blob URL.

‎.changeset/portal-hydration.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@intentface/chat": patch
3+
---
4+
5+
- [Composer] Fix a hydration mismatch when `Composer.Popover` or an overlay `Composer.Panel` is server-rendered.

‎packages/chat/src/composer/panel.tsx‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
// data-ending-style (+ data-side/data-align when positioned) — animate with CSS or Motion.
2222

2323
import { Children, type ReactNode, type RefObject, useCallback, useRef } from "react";
24-
import { createPortal } from "react-dom";
24+
import { Portal } from "../internal/portal";
2525
import type { PrimitiveProps } from "../internal/primitive-props";
2626
import {
2727
type AnchorAlign,
@@ -121,9 +121,5 @@ export const ComposerPanel = ({
121121
},
122122
);
123123

124-
if (positioned) {
125-
if (typeof document === "undefined") return null;
126-
return createPortal(element, document.body);
127-
}
128-
return element;
124+
return positioned ? <Portal>{element}</Portal> : element;
129125
};

‎packages/chat/src/composer/popover.tsx‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
// a flip, and a --anchor-available-height var so the list caps height + scrolls.
1313

1414
import { Children, type ReactNode, useCallback, useRef } from "react";
15-
import { createPortal } from "react-dom";
15+
import { Portal } from "../internal/portal";
1616
import type { PrimitiveProps } from "../internal/primitive-props";
1717
import { useAnchorPositioning } from "../internal/render/positioning";
1818
import { useRenderElement } from "../internal/render/useRenderElement";
@@ -88,6 +88,5 @@ export const ComposerPopover = ({
8888
// Stays mounted (positioning only runs while open, so a closed popover just
8989
// holds its last position). Open/closed is exposed as data-open/data-closed
9090
// for the styled layer to animate against — same as the other parts.
91-
if (typeof document === "undefined") return null;
92-
return createPortal(element, document.body);
91+
return <Portal>{element}</Portal>;
9392
};
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"use client";
2+
3+
import { type ReactNode, useSyncExternalStore } from "react";
4+
import { createPortal } from "react-dom";
5+
6+
// The one way the package portals. A portal can't be server-rendered — there is
7+
// no document to portal into — so the first client render must stay empty too,
8+
// or hydration won't match. useSyncExternalStore renders the server snapshot
9+
// (false) while hydrating and re-renders with the client one right after; a
10+
// client-only mount gets true straight away.
11+
const getSnapshot = () => true;
12+
const getServerSnapshot = () => false;
13+
const subscribe = () => () => {};
14+
const useHydrated = () => useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
15+
16+
export type PortalProps = {
17+
children?: ReactNode;
18+
container?: Element | null;
19+
};
20+
21+
export const Portal = ({ children, container }: PortalProps) => {
22+
const hydrated = useHydrated();
23+
if (!hydrated) return null;
24+
return createPortal(children, container ?? document.body);
25+
};

‎packages/chat/src/tabs/anchored.tsx‎

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
import type { ReactNode } from "react";
44
import { createContext, use, useCallback, useRef, useState } from "react";
5-
import { createPortal } from "react-dom";
65
import { useIsomorphicLayoutEffect } from "../internal/iso-layout-effect";
6+
import { Portal } from "../internal/portal";
77
import type { PrimitiveProps } from "../internal/primitive-props";
88
import type { AnchorAlign, AnchorSide } from "../internal/render/positioning";
99
import { useAnchorPositioning } from "../internal/render/positioning";
@@ -81,11 +81,6 @@ export const TabsPortal = ({ children, container, keepMounted = false }: TabsPor
8181
const store = useTabsContextStore();
8282
const open = useTabsStore(store, selectValue) !== null;
8383

84-
// There is no document to portal into during a server render, and reaching
85-
// for one during the first client render would not match it.
86-
const [hydrated, setHydrated] = useState(false);
87-
useIsomorphicLayoutEffect(() => setHydrated(true), []);
88-
8984
// Kept mounted through the exit, so the surface can animate away instead of
9085
// blinking out. Popup reports when that has finished.
9186
const { mounted, setMounted, transitionStatus } = useTransitionStatus(open);
@@ -96,11 +91,12 @@ export const TabsPortal = ({ children, container, keepMounted = false }: TabsPor
9691
onExitFinished: () => setMounted(false),
9792
};
9893

99-
if (!hydrated || (!mounted && !keepMounted)) return null;
94+
if (!mounted && !keepMounted) return null;
10095

101-
return createPortal(
102-
<SurfaceTransitionContext value={transition}>{children}</SurfaceTransitionContext>,
103-
container ?? document.body,
96+
return (
97+
<Portal container={container}>
98+
<SurfaceTransitionContext value={transition}>{children}</SurfaceTransitionContext>
99+
</Portal>
104100
);
105101
};
106102

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { afterEach, describe, expect, test } from "bun:test";
2+
import { act } from "react";
3+
import { hydrateRoot, type Root } from "react-dom/client";
4+
import { renderToString } from "react-dom/server";
5+
import { Composer } from "../src/composer";
6+
7+
// Portaled parts must hydrate cleanly: the server can't render a portal, so the
8+
// first client render must match its empty output, then portal (CHAT-50).
9+
10+
const App = () => (
11+
<Composer.Root onSubmit={() => {}}>
12+
<Composer.Panel>
13+
<p>panel</p>
14+
</Composer.Panel>
15+
<Composer.Popover>
16+
<p>popover</p>
17+
</Composer.Popover>
18+
<Composer.Container>
19+
<Composer.Textarea aria-label="Message" />
20+
</Composer.Container>
21+
</Composer.Root>
22+
);
23+
24+
describe("portaled parts", () => {
25+
let root: Root | null = null;
26+
afterEach(() => {
27+
act(() => root?.unmount());
28+
root = null;
29+
document.body.innerHTML = "";
30+
});
31+
32+
test("server-render nothing, hydrate without a mismatch, then portal into the body", async () => {
33+
const container = document.createElement("div");
34+
container.innerHTML = renderToString(<App />);
35+
document.body.append(container);
36+
expect(container.querySelector("[data-composer-panel], [data-composer-popover]")).toBeNull();
37+
38+
const errors: unknown[] = [];
39+
await act(async () => {
40+
root = hydrateRoot(container, <App />, { onRecoverableError: (error) => errors.push(error) });
41+
});
42+
43+
expect(errors).toEqual([]);
44+
expect(document.querySelector("body > [data-composer-panel]")?.textContent).toBe("panel");
45+
expect(document.querySelector("body > [data-composer-popover]")?.textContent).toBe("popover");
46+
});
47+
});

0 commit comments

Comments
 (0)