From 98ff8b629f0a07592b28aea27fd616d1dc2caae5 Mon Sep 17 00:00:00 2001 From: Trevor Burnham Date: Sat, 15 Aug 2026 17:53:43 -0400 Subject: [PATCH] fix: Render the Portal probe span during SSR The probe span added in #214 was gated on `typeof document !== 'undefined'`, so the server rendered nothing while the client's first render rendered the span. React reports that as a hydration mismatch and discards the subtree. Rendering the span on both passes makes the markup agree. It is display:none and is replaced by the portal on the first commit. Fixes AWSUI-62219 --- .../portal/__tests__/portal.ssr.test.tsx | 31 +++++++++++++++++++ src/internal/portal/index.tsx | 5 +-- 2 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 src/internal/portal/__tests__/portal.ssr.test.tsx diff --git a/src/internal/portal/__tests__/portal.ssr.test.tsx b/src/internal/portal/__tests__/portal.ssr.test.tsx new file mode 100644 index 0000000..0e9fe18 --- /dev/null +++ b/src/internal/portal/__tests__/portal.ssr.test.tsx @@ -0,0 +1,31 @@ +/** + * @jest-environment node + */ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import Portal from '../index'; + +// The probe span must be part of the server markup, otherwise it appears as an +// added element during hydration and React discards the whole subtree. +// See https://github.com/cloudscape-design/component-toolkit/pull/214 +test('renders the probe span so that server and client markup match', () => { + const content = renderToStaticMarkup( + +

Hello!

+
+ ); + expect(content).toBe(''); +}); + +test('does not render portal children on the server', () => { + const content = renderToStaticMarkup( + +

Hello!

+
+ ); + expect(content).not.toContain('Hello!'); +}); diff --git a/src/internal/portal/index.tsx b/src/internal/portal/index.tsx index 8c30f8e..55c0753 100644 --- a/src/internal/portal/index.tsx +++ b/src/internal/portal/index.tsx @@ -88,8 +88,9 @@ export default function Portal({ // On the first render, activeContainer is null because the layout effect hasn't // created it yet. We render a hidden probe span so the effect can read // ref.current.ownerDocument to discover the correct document (e.g. inside iframes). - // In SSR there's no document, so we return null to match the previous behavior. - if (!activeContainer && typeof document !== 'undefined') { + // The span is rendered during SSR too, so that the server markup matches the first + // client render and hydration does not fail. + if (!activeContainer) { return ; }