Skip to content
2 changes: 1 addition & 1 deletion .github/workflows/ci-reusable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ jobs:

- name: Clone and setup Enact framework
run: |
git clone --branch=develop --depth 1 https://github.com/enactjs/enact ../enact
git clone --branch=feature/NXT-10275 --depth 1 https://github.com/enactjs/enact ../enact
pushd ../enact
npm install
npm run lerna exec -- --ignore enact-sampler --concurrency 1 -- npm --no-package-lock install
Expand Down
23 changes: 20 additions & 3 deletions Popup/Popup.js
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ const Popup = (props) => {
checkPropTypes(Popup, props);

const componentProps = setDefaultProps(props, popupDefaultProps);
const {noAnimation, noAutoDismiss, no5WayClose, onClose, open, position, scrimType, spotlightRestrict, ...rest} = componentProps;
const {noAnimation, noAutoDismiss, no5WayClose, onClose, open, position, prerender, scrimType, spotlightRestrict, ...rest} = componentProps;

// Assign the needed props to the rest object for the child component
Object.assign(rest, {noAnimation, position, spotlightRestrict});
Expand Down Expand Up @@ -511,9 +511,12 @@ const Popup = (props) => {
// If the popup is open on mount, we need to pause spotlight so nothing steals focus
// while the popup is rendering.
pausedRef.current.pause();
if (getContainerNode(containerIdRef.current)) {
// With `prerender`, the content is first rendered inline and then relocated into the portal, remounting the subtree.
// Focusing now would spot the wrong element because the content's spotlight config isn't established yet,
// and that selection is restored after the relocation.
// Skip the early spot and let the post-relocation `onOpen` focus run.
if (!prerender && getContainerNode(containerIdRef.current)) {
spotPopupContent();

}
}

Expand Down Expand Up @@ -544,6 +547,7 @@ const Popup = (props) => {
open={floatLayerOpen}
onOpen={handleFloatingLayerOpen}
onDismiss={handleDismiss}
prerender={prerender}
scrimType={scrimType}
>
<SkinnedPopupBase
Expand Down Expand Up @@ -656,6 +660,19 @@ Popup.propTypes = /** @lends limestone/Popup.Popup.prototype */ {
*/
position: PropTypes.oneOf(['bottom', 'bottom left', 'bottom right', 'center', 'fullscreen', 'left', 'right', 'top', 'top left', 'top right']),

/**
* Enables prerendering support.
*
* When `true`, the popup content is rendered inline on the first render (the server prerender
* pass and the initial client render) and relocated into the floating layer via a portal once
* mounted, so the popup can be captured in prerendered HTML and hydrate without a mismatch.
*
* @type {Boolean}
* @default false
* @public
*/
prerender: PropTypes.bool,

/**
* Scrim type.
*
Expand Down
19 changes: 17 additions & 2 deletions PopupTabLayout/PopupTabLayout.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import componentCss from './PopupTabLayout.module.less';

// List all the props from PopupTabLayout that we want to move from this component's root onto PopupTabLayout.
const popupPropList = ['noAutoDismiss', 'onHide', 'onKeyDown', 'onShow', 'open',
'position', 'scrimType', 'spotlightId', 'spotlightRestrict', 'id', 'className',
'position', 'prerender', 'scrimType', 'spotlightId', 'spotlightRestrict', 'id', 'className',
'style', 'noAnimation', 'onClose'];

const OptimizedContainer = SpotlightContainerDecorator(
Expand Down Expand Up @@ -198,7 +198,7 @@ const PopupTabLayoutBase = kind({
* Optimizes PopupTabLayout without Popup when true.
*
* @type {Boolean}
* @private
* @public
*/
optimized: PropTypes.bool,

Expand All @@ -219,6 +219,20 @@ const PopupTabLayoutBase = kind({
*/
position: PropTypes.oneOf(['left']),

/**
* Enables prerendering support.
*
* When `true`, the popup content is rendered inline on the first render and relocated into
* the floating layer via a portal once mounted, so the `PopupTabLayout` can be captured in
* prerendered HTML and hydrate without a mismatch. Unlike `optimized`, this keeps the full
* {@link limestone/Popup.Popup|Popup}/`FloatingLayer` behavior.
*
* @type {Boolean}
* @default false
* @private
*/
prerender: PropTypes.bool,

/**
* Scrim type.
*
Expand Down Expand Up @@ -302,6 +316,7 @@ const PopupTabLayoutBase = kind({
delete popupProps.noAutoDismiss;
delete popupProps.onHide;
delete popupProps.onShow;
delete popupProps.prerender;
delete popupProps.scrimType;
}

Expand Down
100 changes: 100 additions & 0 deletions PopupTabLayout/tests/PopupTabLayout-prerender-poc-specs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/*
* Prerendering PopupTabLayout
*
* Demonstrates that a 4-tab PopupTabLayout CANNOT be prerendered through the default
* (React portal-based) Popup path, but CAN be prerendered using the `prerender` private prop,
* which swaps the FloatingLayer/createPortal for a plain div.
*
* "Prerendering" here means a single `react-dom/server` renderToString() pass (mount phase only, no lifecycle,
* no setState-driven second render). See dev-utils/plugins/PrerenderPlugin/vdom-server-render.js
*/

/* eslint-disable testing-library/render-result-naming-convention */
// `renderToString` returns an HTML string, not a testing-library render result.

import {FloatingLayerDecorator} from '@enact/ui/FloatingLayer';
import {renderToString} from 'react-dom/server';

import {Item} from '../../Item';
import {PopupTabLayout, Tab, TabPanel, TabPanels} from '../PopupTabLayout';

const FloatingLayerController = FloatingLayerDecorator('div');

const FourTabLayout = (props) => (
<PopupTabLayout open {...props}>
<Tab title="Picture">
<TabPanels>
<TabPanel>
<Item>Brightness</Item>
<Item>Contrast</Item>
</TabPanel>
</TabPanels>
</Tab>
<Tab title="Sound">
<TabPanels>
<TabPanel>
<Item>Balance</Item>
</TabPanel>
</TabPanels>
</Tab>
<Tab title="Channels">
<TabPanels>
<TabPanel>
<Item>Auto Tuning</Item>
</TabPanel>
</TabPanels>
</Tab>
<Tab title="Connection">
<TabPanels>
<TabPanel>
<Item>Network</Item>
</TabPanel>
</TabPanels>
</Tab>
</PopupTabLayout>
);

const prerender = (element) => renderToString(
<FloatingLayerController>{element}</FloatingLayerController>
);

describe('PopupTabLayout prerendering PoC', () => {
const tabTitles = ['Picture', 'Sound', 'Channels', 'Connection'];

test('default (portal) path: a single renderToString pass produces NO popup content', () => {
// The default path renders through Popup -> FloatingLayer, whose first render returns
// null until `readyToRender` flips post-mount. A prerender pass never gets that second
// render, so the tab content is absent from the HTML
const html = prerender(<FourTabLayout />);

tabTitles.forEach((title) => {
expect(html).not.toContain(title);
});
});

test('optimized path: a single renderToString pass produces the full 4-tab content', () => {
const html = prerender(<FourTabLayout optimized />);

// All four tab titles are present in the prerendered HTML
tabTitles.forEach((title) => {
expect(html).toContain(title);
});

// the content of the first (active) tab's panel is also present.
expect(html).toContain('Brightness');
expect(html).toContain('Contrast');
});

test('prerender path (real Popup/FloatingLayer): a single renderToString pass produces the full 4-tab content', () => {
// Unlike `optimized`, this keeps the real Popup + FloatingLayer; the `prerender` prop makes
// FloatingLayer render its content inline during the prerender pass instead of returning null.
const html = prerender(<FourTabLayout prerender />);

tabTitles.forEach((title) => {
expect(html).toContain(title);
});

expect(html).toContain('Brightness');
expect(html).toContain('Contrast');
});
});
5 changes: 4 additions & 1 deletion TabLayout/RefocusDecorator.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {isWindowReady} from '@enact/core/snapshot';
import {checkPropTypes} from '@enact/core/util';
import Spotlight from '@enact/spotlight';
import {useId} from '@enact/ui/internal/IdProvider';
Expand All @@ -22,8 +23,10 @@ const getNavigableFilter = (spotlightId, collapsed) => (elem) => (
);

function useScreenOrientation () {
// Guard against a prerender/SSR pass where `window` is unavailable. The orientation defaults to
// 'landscape' until a window is ready; the resize effect keeps it current on the client.
const getOrientation = () =>
window.innerWidth > window.innerHeight ? 'landscape' : 'portrait';
(isWindowReady() && window.innerWidth <= window.innerHeight) ? 'portrait' : 'landscape';

const [orientation, setOrientation] = useState(getOrientation());

Expand Down
36 changes: 36 additions & 0 deletions samples/qa-popuptablayout-prerender/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"name": "qa-popuptablayout-prerender",
"version": "0.1.0",
"description": "A qa sample for verifying prerendering of PopupTabLayout",
"author": "",
"main": "src/index.js",
"scripts": {
"serve": "enact serve",
"pack": "enact pack",
"pack-p": "enact pack -p",
"watch": "enact pack --watch",
"clean": "enact clean",
"lint": "enact lint --strict .",
"test": "enact test",
"test-watch": "enact test --watch"
},
"license": "Apache-2.0",
"private": true,
"repository": "",
"enact": {
"title": "QA PopupTabLayout Prerender",
"theme": "limestone",
"isomorphic": true
},
"dependencies": {
"@enact/core": "^5.5.1",
"@enact/i18n": "^5.5.1",
"@enact/limestone": "../../",
"@enact/spotlight": "^5.5.1",
"@enact/ui": "^5.5.1",
"ilib": "^14.22.0",
"prop-types": "^15.8.1",
"react": "^19.2.7",
"react-dom": "^19.2.7"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"files": []
}
11 changes: 11 additions & 0 deletions samples/qa-popuptablayout-prerender/src/App/App.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import ThemeDecorator from '@enact/limestone/ThemeDecorator';

import MainPanel from '../views/MainPanel';

const App = (props) => (
<div {...props}>
<MainPanel />
</div>
);

export default ThemeDecorator(App);
3 changes: 3 additions & 0 deletions samples/qa-popuptablayout-prerender/src/App/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"main": "App.js"
}
17 changes: 17 additions & 0 deletions samples/qa-popuptablayout-prerender/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/* global ENACT_PACK_ISOMORPHIC */
import {createRoot, hydrateRoot} from 'react-dom/client';

import App from './App';

const appElement = (<App />);

// In a browser environment, render instead of exporting
if (typeof window !== 'undefined') {
if (ENACT_PACK_ISOMORPHIC) {
hydrateRoot(document.getElementById('root'), appElement);
} else {
createRoot(document.getElementById('root')).render(appElement);
}
}

export default appElement;
72 changes: 72 additions & 0 deletions samples/qa-popuptablayout-prerender/src/views/MainPanel.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@

import spotlight from '@enact/spotlight';
import Button from '@enact/limestone/Button';
import Heading from '@enact/limestone/Heading';
import Item from '@enact/limestone/Item';
import {Header} from '@enact/limestone/Panels';
import PopupTabLayout, {Tab, TabPanel, TabPanels} from '@enact/limestone/PopupTabLayout';
import {useCallback, useState} from 'react';

// Force pointer mode off so spotlight 5-way focus behaves like a TV (mirrors the wdio test setup).
spotlight.setPointerMode(false);

// A 4-tab PopupTabLayout that opens on launch. With `prerender`, FloatingLayer renders the
// content inline during the prerender pass (and the initial client render), then relocates it
// into the floating layer via a portal once mounted — so the tabs appear in the prerendered HTML
// and hydrate without a mismatch. Toggle `optimized`/`prerender` below to compare.
const MainPanel = () => {
// Opened on launch so the popup is part of the initial (prerendered) render.
const [open, setOpen] = useState(true);

const handleClose = useCallback(() => setOpen(false), []);
const handleOpen = useCallback(() => setOpen(true), []);

return (
<div>
<Heading showLine>PopupTabLayout prerender sample</Heading>
<Button onClick={handleOpen}>Open PopupTabLayout</Button>

<PopupTabLayout open={open} prerender onClose={handleClose}>
<Tab icon="picture" title="Picture">
<TabPanels>
<TabPanel>
<Header title="Picture" type="compact" />
<Item>Brightness</Item>
<Item>Contrast</Item>
<Item>Color</Item>
</TabPanel>
</TabPanels>
</Tab>
<Tab icon="sound" title="Sound">
<TabPanels>
<TabPanel>
<Header title="Sound" type="compact" />
<Item>Balance</Item>
<Item>Mode</Item>
</TabPanel>
</TabPanels>
</Tab>
<Tab icon="bluetooth" title="Channels">
<TabPanels>
<TabPanel>
<Header title="Channels" type="compact" />
<Item>Auto Tuning</Item>
<Item>Channel Manager</Item>
</TabPanel>
</TabPanels>
</Tab>
<Tab icon="gear" title="Connection">
<TabPanels>
<TabPanel>
<Header title="Connection" type="compact" />
<Item>Network</Item>
<Item>Device Connector</Item>
</TabPanel>
</TabPanels>
</Tab>
</PopupTabLayout>
</div>
);
};

export default MainPanel;
4 changes: 4 additions & 0 deletions samples/sampler/stories/default/PopupTabLayout.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ const PopupTabLayoutSamplesBase = (props) => {
onShow={action('onShow')}
scrimType={args['scrimType']}
spotlightRestrict={args['spotlightRestrict']}
optimized={args['optimized']}
prerender={args['prerender']}
>
<Tab
icon={includeIcons ? 'picture' : null}
Expand Down Expand Up @@ -219,6 +221,8 @@ const PopupTabLayoutSamples = I18nContextDecorator(
export const _PopupTabLayout = (args) => <PopupTabLayoutSamples args={args} />;

boolean('include icons', _PopupTabLayout, Config, true);
boolean('optimized', _PopupTabLayout, Config);
boolean('prerender', _PopupTabLayout, Config);
boolean('noAnimation', _PopupTabLayout, Config);
boolean('noAutoDismiss', _PopupTabLayout, Config);
select(
Expand Down
Loading
Loading