diff --git a/.github/workflows/ci-reusable.yml b/.github/workflows/ci-reusable.yml index be48a6a00..714e53c70 100644 --- a/.github/workflows/ci-reusable.yml +++ b/.github/workflows/ci-reusable.yml @@ -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 diff --git a/Popup/Popup.js b/Popup/Popup.js index 80c6e6368..aefcae571 100644 --- a/Popup/Popup.js +++ b/Popup/Popup.js @@ -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}); @@ -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(); - } } @@ -544,6 +547,7 @@ const Popup = (props) => { open={floatLayerOpen} onOpen={handleFloatingLayerOpen} onDismiss={handleDismiss} + prerender={prerender} scrimType={scrimType} > ( + + + + + Brightness + Contrast + + + + + + + Balance + + + + + + + Auto Tuning + + + + + + + Network + + + + +); + +const prerender = (element) => renderToString( + {element} +); + +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(); + + tabTitles.forEach((title) => { + expect(html).not.toContain(title); + }); + }); + + test('optimized path: a single renderToString pass produces the full 4-tab content', () => { + const html = prerender(); + + // 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(); + + tabTitles.forEach((title) => { + expect(html).toContain(title); + }); + + expect(html).toContain('Brightness'); + expect(html).toContain('Contrast'); + }); +}); diff --git a/TabLayout/RefocusDecorator.js b/TabLayout/RefocusDecorator.js index 85d0de8ad..16cb0b8e2 100644 --- a/TabLayout/RefocusDecorator.js +++ b/TabLayout/RefocusDecorator.js @@ -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'; @@ -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()); diff --git a/samples/qa-popuptablayout-prerender/package.json b/samples/qa-popuptablayout-prerender/package.json new file mode 100644 index 000000000..8a73e1365 --- /dev/null +++ b/samples/qa-popuptablayout-prerender/package.json @@ -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" + } +} diff --git a/samples/qa-popuptablayout-prerender/resources/ilibmanifest.json b/samples/qa-popuptablayout-prerender/resources/ilibmanifest.json new file mode 100644 index 000000000..d946318dc --- /dev/null +++ b/samples/qa-popuptablayout-prerender/resources/ilibmanifest.json @@ -0,0 +1,3 @@ +{ + "files": [] +} diff --git a/samples/qa-popuptablayout-prerender/src/App/App.js b/samples/qa-popuptablayout-prerender/src/App/App.js new file mode 100644 index 000000000..fd888042f --- /dev/null +++ b/samples/qa-popuptablayout-prerender/src/App/App.js @@ -0,0 +1,11 @@ +import ThemeDecorator from '@enact/limestone/ThemeDecorator'; + +import MainPanel from '../views/MainPanel'; + +const App = (props) => ( +
+ +
+); + +export default ThemeDecorator(App); diff --git a/samples/qa-popuptablayout-prerender/src/App/package.json b/samples/qa-popuptablayout-prerender/src/App/package.json new file mode 100644 index 000000000..bf7e48160 --- /dev/null +++ b/samples/qa-popuptablayout-prerender/src/App/package.json @@ -0,0 +1,3 @@ +{ + "main": "App.js" +} diff --git a/samples/qa-popuptablayout-prerender/src/index.js b/samples/qa-popuptablayout-prerender/src/index.js new file mode 100644 index 000000000..a601c327f --- /dev/null +++ b/samples/qa-popuptablayout-prerender/src/index.js @@ -0,0 +1,17 @@ +/* global ENACT_PACK_ISOMORPHIC */ +import {createRoot, hydrateRoot} from 'react-dom/client'; + +import App from './App'; + +const appElement = (); + +// 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; diff --git a/samples/qa-popuptablayout-prerender/src/views/MainPanel.js b/samples/qa-popuptablayout-prerender/src/views/MainPanel.js new file mode 100644 index 000000000..185854b4b --- /dev/null +++ b/samples/qa-popuptablayout-prerender/src/views/MainPanel.js @@ -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 ( +
+ PopupTabLayout prerender sample + + + + + + +
+ Brightness + Contrast + Color + + + + + + +
+ Balance + Mode + + + + + + +
+ Auto Tuning + Channel Manager + + + + + + +
+ Network + Device Connector + + + + +
+ ); +}; + +export default MainPanel; diff --git a/samples/sampler/stories/default/PopupTabLayout.js b/samples/sampler/stories/default/PopupTabLayout.js index 7557c6195..7c385e431 100644 --- a/samples/sampler/stories/default/PopupTabLayout.js +++ b/samples/sampler/stories/default/PopupTabLayout.js @@ -93,6 +93,8 @@ const PopupTabLayoutSamplesBase = (props) => { onShow={action('onShow')} scrimType={args['scrimType']} spotlightRestrict={args['spotlightRestrict']} + optimized={args['optimized']} + prerender={args['prerender']} > ; boolean('include icons', _PopupTabLayout, Config, true); +boolean('optimized', _PopupTabLayout, Config); +boolean('prerender', _PopupTabLayout, Config); boolean('noAnimation', _PopupTabLayout, Config); boolean('noAutoDismiss', _PopupTabLayout, Config); select( diff --git a/tests/ui/specs/PopupTabLayout/PopupTabLayout-prerender-specs.js b/tests/ui/specs/PopupTabLayout/PopupTabLayout-prerender-specs.js new file mode 100644 index 000000000..322f8028c --- /dev/null +++ b/tests/ui/specs/PopupTabLayout/PopupTabLayout-prerender-specs.js @@ -0,0 +1,102 @@ +const Page = require('./PopupTabLayoutPage'); + +describe('PopupTabLayout prerender/optimized', function () { + const {popupTabLayout} = Page.components; + + describe('prerender', function () { + beforeEach(async function () { + await Page.open('', '?prerender'); + }); + + it('should render the popup open with the first tab\'s view', async function () { + await Page.waitForExist('#tabLayout'); + + const expected = 'display'; + const actual = await popupTabLayout.currentView.getAttribute('id'); + + expect(actual).toBe(expected); + }); + + it('should render its content inside the floating layer (portal) at runtime', async function () { + await Page.waitForExist('#tabLayout'); + + expect(await Page.isContentInFloatingLayer()).toBe(true); + }); + + it('should not apply the `optimized` class', async function () { + await Page.waitForExist('#tabLayout'); + + const className = await popupTabLayout.self.getAttribute('class'); + + expect(className).not.toContain('optimized'); + }); + + it('should support 5-way tab navigation like the default', async function () { + const soundId = 'sound'; + + await Page.delay(1000); + await Page.spotlightDown(); + await Page.waitForExist(`#${soundId}`); + + const expected = soundId; + const actual = await popupTabLayout.currentView.getAttribute('id'); + + expect(actual).toBe(expected); + }); + + it('should close the popup on back when the focus is on the tabs', async function () { + await Page.delay(500); + await Page.waitTransitionEnd(1500, 'waiting for popup to close', async () => { + await Page.backKey(); + }); + + await Page.delay(500); + const expected = false; + const actual = await $('#tabLayout').isExisting(); + + expect(actual).toBe(expected); + }); + }); + + describe('optimized', function () { + beforeEach(async function () { + await Page.open('', '?optimized'); + }); + + it('should render the popup open with the first tab\'s view', async function () { + await Page.waitForExist('#tabLayout'); + + const expected = 'display'; + const actual = await popupTabLayout.currentView.getAttribute('id'); + + expect(actual).toBe(expected); + }); + + it('should render its content inline, not inside the floating layer', async function () { + await Page.waitForExist('#tabLayout'); + + expect(await Page.isContentInFloatingLayer()).toBe(false); + }); + + it('should apply the `optimized` class', async function () { + await Page.waitForExist('#tabLayout'); + + const className = await popupTabLayout.self.getAttribute('class'); + + expect(className).toContain('optimized'); + }); + + it('should support 5-way tab navigation', async function () { + const soundId = 'sound'; + + await Page.delay(1000); + await Page.spotlightDown(); + await Page.waitForExist(`#${soundId}`); + + const expected = soundId; + const actual = await popupTabLayout.currentView.getAttribute('id'); + + expect(actual).toBe(expected); + }); + }); +}); diff --git a/tests/ui/specs/PopupTabLayout/PopupTabLayoutPage.js b/tests/ui/specs/PopupTabLayout/PopupTabLayoutPage.js index 4271a548a..f12cc13bf 100644 --- a/tests/ui/specs/PopupTabLayout/PopupTabLayoutPage.js +++ b/tests/ui/specs/PopupTabLayout/PopupTabLayoutPage.js @@ -92,6 +92,14 @@ class PopupTabLayoutPage extends Page { return document.activeElement.getAttribute('aria-label'); }); } + + async isContentInFloatingLayer (id = 'tabLayout') { + return await browser.execute(function (componentId) { + const floatLayer = document.getElementById('floatLayer'); + const content = document.getElementById(componentId); + return Boolean(floatLayer && content && floatLayer.contains(content)); + }, id); + } } module.exports = new PopupTabLayoutPage();