From 1488e36c9f792ce94d12e7741fe150adfc31cbee Mon Sep 17 00:00:00 2001 From: "Dalton.m" Date: Mon, 13 Apr 2026 16:47:45 +0800 Subject: [PATCH 1/5] feat: init error --- bridge/typings/package.json | 2 +- webf/example/lib/main.dart | 8 ++++---- .../example/macos/Flutter/GeneratedPluginRegistrant.swift | 2 -- webf/example/macos/Runner.xcodeproj/project.pbxproj | 6 ------ webf/example/pubspec.yaml | 8 ++++---- 5 files changed, 9 insertions(+), 17 deletions(-) diff --git a/bridge/typings/package.json b/bridge/typings/package.json index b029f608d0..c4b927b5c5 100644 --- a/bridge/typings/package.json +++ b/bridge/typings/package.json @@ -1,6 +1,6 @@ { "name": "@openwebf/webf-enterprise-typings", - "version": "0.24.9", + "version": "0.24.26", "main": "index.d.ts", "types": "index.d.ts", "files": [ diff --git a/webf/example/lib/main.dart b/webf/example/lib/main.dart index aaaebb1b2f..78437df282 100644 --- a/webf/example/lib/main.dart +++ b/webf/example/lib/main.dart @@ -16,13 +16,13 @@ import 'package:webf/devtools.dart'; import 'package:flutter/cupertino.dart'; import 'package:webf_example/cronet_adapter.dart'; import 'package:webf_cupertino_ui/webf_cupertino_ui.dart'; -import 'package:webf_shadcn_ui/webf_shadcn_ui.dart'; +// import 'package:webf_shadcn_ui/webf_shadcn_ui.dart'; import 'package:day_night_switcher/day_night_switcher.dart'; import 'package:adaptive_theme/adaptive_theme.dart'; import 'custom_hybrid_history_delegate.dart'; -import 'package:webf_lucide_icons/webf_lucide_icons.dart'; +// import 'package:webf_lucide_icons/webf_lucide_icons.dart'; import 'package:webf_share/webf_share.dart'; import 'package:webf_sqflite/webf_sqflite.dart'; import 'package:webf_camera/webf_camera.dart'; @@ -57,8 +57,8 @@ void main() async { installWebFCupertinoUI(); installWebFCamera(); installWebFVideoPlayer(); - installWebFShadcnUI(); - installWebFLucideIcons(); + // installWebFShadcnUI(); + // installWebFLucideIcons(); WebF.defineModule((context) => ShareModule(context)); WebF.defineModule((context) => SQFliteModule(context)); diff --git a/webf/example/macos/Flutter/GeneratedPluginRegistrant.swift b/webf/example/macos/Flutter/GeneratedPluginRegistrant.swift index bc8139de61..45f5a64765 100644 --- a/webf/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/webf/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,7 +10,6 @@ import device_info_plus import file_picker import file_selector_macos import flutter_blue_plus_darwin -import path_provider_foundation import share_plus import shared_preferences_foundation import sqflite_darwin @@ -24,7 +23,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin")) - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) diff --git a/webf/example/macos/Runner.xcodeproj/project.pbxproj b/webf/example/macos/Runner.xcodeproj/project.pbxproj index 7b78510725..baeb014487 100644 --- a/webf/example/macos/Runner.xcodeproj/project.pbxproj +++ b/webf/example/macos/Runner.xcodeproj/project.pbxproj @@ -267,14 +267,11 @@ "${BUILT_PRODUCTS_DIR}/file_picker/file_picker.framework", "${BUILT_PRODUCTS_DIR}/file_selector_macos/file_selector_macos.framework", "${BUILT_PRODUCTS_DIR}/flutter_blue_plus_darwin/flutter_blue_plus_darwin.framework", - "${BUILT_PRODUCTS_DIR}/path_provider_foundation/path_provider_foundation.framework", "${BUILT_PRODUCTS_DIR}/share_plus/share_plus.framework", "${BUILT_PRODUCTS_DIR}/shared_preferences_foundation/shared_preferences_foundation.framework", "${BUILT_PRODUCTS_DIR}/sqflite_darwin/sqflite_darwin.framework", "${BUILT_PRODUCTS_DIR}/url_launcher_macos/url_launcher_macos.framework", "${BUILT_PRODUCTS_DIR}/video_player_avfoundation/video_player_avfoundation.framework", - "${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/webf/macos/libwebf.dylib", - "${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/webf/macos/libquickjs.dylib", "${BUILT_PRODUCTS_DIR}/webf/webf.framework", ); name = "[CP] Embed Pods Frameworks"; @@ -286,14 +283,11 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/file_picker.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/file_selector_macos.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_blue_plus_darwin.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/path_provider_foundation.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/share_plus.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/shared_preferences_foundation.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/sqflite_darwin.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/url_launcher_macos.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/video_player_avfoundation.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libwebf.dylib", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libquickjs.dylib", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/webf.framework", ); runOnlyForDeploymentPostprocessing = 0; diff --git a/webf/example/pubspec.yaml b/webf/example/pubspec.yaml index 86b23ce89a..feed3874aa 100644 --- a/webf/example/pubspec.yaml +++ b/webf/example/pubspec.yaml @@ -31,10 +31,10 @@ dependencies: collection: ^1.18.0 auto_size_text: ^3.0.0 webf_cupertino_ui: ^0.3.37 - webf_lucide_icons: - path: ../../native_uis/webf_lucide_icons - webf_shadcn_ui: - path: ../../native_uis/webf_shadcn_ui + # webf_lucide_icons: + # path: ../../native_uis/webf_lucide_icons + # webf_shadcn_ui: + # path: ../../native_uis/webf_shadcn_ui webf_share: path: ../../native_plugins/share webf_sqflite: From 745c08e76239d3dfd61d824d28577c2407f8e97c Mon Sep 17 00:00:00 2001 From: "Dalton.m" Date: Tue, 14 Apr 2026 14:15:29 +0800 Subject: [PATCH 2/5] feat(webf): add webf-global-root element and GlobalRootView widget --- webf/lib/html.dart | 1 + webf/lib/src/dom/element_registry.dart | 3 + webf/lib/src/html/global_root.dart | 75 ++++++++++++++++++++++ webf/lib/src/launcher/view_controller.dart | 30 +++++++++ webf/lib/src/widget/global_root_view.dart | 74 +++++++++++++++++++++ webf/lib/widget.dart | 1 + 6 files changed, 184 insertions(+) create mode 100644 webf/lib/src/html/global_root.dart create mode 100644 webf/lib/src/widget/global_root_view.dart diff --git a/webf/lib/html.dart b/webf/lib/html.dart index 6e22a220fd..331f9227b3 100644 --- a/webf/lib/html.dart +++ b/webf/lib/html.dart @@ -28,6 +28,7 @@ export 'src/html/touch_area.dart'; export 'src/html/sections.dart'; export 'src/html/semantics_text.dart'; export 'src/html/router_link.dart'; +export 'src/html/global_root.dart'; export 'src/html/template.dart'; export 'src/html/form/textarea.dart'; export 'src/html/pseudo.dart'; diff --git a/webf/lib/src/dom/element_registry.dart b/webf/lib/src/dom/element_registry.dart index 26f34040de..e439825a9c 100644 --- a/webf/lib/src/dom/element_registry.dart +++ b/webf/lib/src/dom/element_registry.dart @@ -241,6 +241,9 @@ void defineBuiltInElements() { // Hybrid Routers defineElement(ROUTER_LINK, (context) => RouterLinkElement(context)); + // Global Root + defineElement(GLOBAL_ROOT, (context) => GlobalRootElement(context)); + // SVG Elements defineElement(SVG, (context) => FlutterSvgElement(context)); } diff --git a/webf/lib/src/html/global_root.dart b/webf/lib/src/html/global_root.dart new file mode 100644 index 0000000000..79bb1a55d4 --- /dev/null +++ b/webf/lib/src/html/global_root.dart @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2024-present The OpenWebF Company. All rights reserved. + * Licensed under GNU GPL with Enterprise exception. + */ + +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart' as flutter; +import 'package:webf/bridge.dart'; +import 'package:webf/css.dart'; +import 'package:webf/dom.dart'; +import 'package:webf/rendering.dart'; +import 'package:webf/widget.dart'; + +// ignore: constant_identifier_names +const GLOBAL_ROOT = 'WEBF-GLOBAL-ROOT'; + +class GlobalRootElement extends WidgetElement { + GlobalRootElement(BindingContext? context) : super(context); + + @override + Map get defaultStyle => {DISPLAY: BLOCK}; + + @override + flutter.Key get key => const flutter.ValueKey('WEBF_GLOBAL_ROOT'); + + @override + void connectedCallback() { + super.connectedCallback(); + ownerView.setGlobalRoot(this); + } + + @override + void disconnectedCallback() { + super.disconnectedCallback(); + ownerView.removeGlobalRoot(this); + } + + @override + WebFWidgetElementState createState() { + return GlobalRootElementState(this); + } +} + +class GlobalRootElementState extends WebFWidgetElementState { + GlobalRootElementState(super.widgetElement); + + @override + GlobalRootElement get widgetElement => super.widgetElement as GlobalRootElement; + + @override + flutter.Widget build(flutter.BuildContext context) { + List children = []; + for (var node in widgetElement.childNodes) { + if (node is Element && + (node.renderStyle.position == CSSPositionType.sticky || + node.renderStyle.position == CSSPositionType.absolute)) { + children.add(PositionPlaceHolder(node.holderAttachedPositionedElement!, node)); + children.add(node.toWidget()); + continue; + } else if (node is Element && node.renderStyle.position == CSSPositionType.fixed) { + children.add(PositionPlaceHolder(node.holderAttachedPositionedElement!, node)); + } else { + children.add(node.toWidget()); + } + } + + return WebFWidgetElementChild( + child: WebFHTMLElement( + tagName: 'GLOBAL_ROOT', + inlineStyle: const {'position': 'relative'}, + controller: widgetElement.ownerDocument.controller, + parentElement: widgetElement, + children: children)); + } +} diff --git a/webf/lib/src/launcher/view_controller.dart b/webf/lib/src/launcher/view_controller.dart index 1b53c0bbe7..c805fe95ba 100644 --- a/webf/lib/src/launcher/view_controller.dart +++ b/webf/lib/src/launcher/view_controller.dart @@ -205,6 +205,36 @@ class WebFViewController with Diagnosticable implements WidgetsBindingObserver { final Map _hybridRouterViews = {}; + // Global root element for rendering content outside of hybrid routes. + WidgetElement? _globalRootElement; + final List _globalRootListeners = []; + + WidgetElement? get globalRoot => _globalRootElement; + + void setGlobalRoot(WidgetElement element) { + _globalRootElement = element; + for (final listener in _globalRootListeners) { + listener(); + } + } + + void removeGlobalRoot(WidgetElement element) { + if (_globalRootElement == element) { + _globalRootElement = null; + for (final listener in _globalRootListeners) { + listener(); + } + } + } + + void addGlobalRootListener(VoidCallback listener) { + _globalRootListeners.add(listener); + } + + void removeGlobalRootListener(VoidCallback listener) { + _globalRootListeners.remove(listener); + } + void setHybridRouterView(String path, WidgetElement root) { _hybridRouterViews[path] = root; diff --git a/webf/lib/src/widget/global_root_view.dart b/webf/lib/src/widget/global_root_view.dart new file mode 100644 index 0000000000..ac470038f6 --- /dev/null +++ b/webf/lib/src/widget/global_root_view.dart @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2024-present The OpenWebF Company. All rights reserved. + * Licensed under GNU GPL with Enterprise exception. + */ + +import 'package:flutter/widgets.dart'; +import 'package:webf/launcher.dart'; +import 'package:webf/rendering.dart'; +import 'package:webf/widget.dart'; + +/// A widget that renders the content of `` element. +/// +/// Place this in a [Stack] above your route content so that global overlays +/// (modals, toasts, etc.) are always visible regardless of the current route. +/// +/// This widget listens for globalRoot changes and rebuilds automatically. +class WebFGlobalRootView extends StatefulWidget { + final WebFController controller; + + const WebFGlobalRootView({super.key, required this.controller}); + + @override + State createState() => _WebFGlobalRootViewState(); +} + +class _WebFGlobalRootViewState extends State { + VoidCallback? _listener; + + @override + void initState() { + super.initState(); + _listener = () { + if (mounted) setState(() {}); + }; + widget.controller.view.addGlobalRootListener(_listener!); + } + + @override + void didUpdateWidget(covariant WebFGlobalRootView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.view.removeGlobalRootListener(_listener!); + widget.controller.view.addGlobalRootListener(_listener!); + } + } + + @override + void dispose() { + if (_listener != null) { + widget.controller.view.removeGlobalRootListener(_listener!); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final globalRoot = widget.controller.view.globalRoot; + if (globalRoot == null) { + return const SizedBox.shrink(); + } + + return IgnorePointer( + ignoring: false, + child: WebFContext( + controller: widget.controller, + child: WebFRouterViewport( + controller: widget.controller, + key: globalRoot.key, + children: [globalRoot.toWidget()], + ), + ), + ); + } +} diff --git a/webf/lib/widget.dart b/webf/lib/widget.dart index 61b45a6b07..1c2e7abca1 100644 --- a/webf/lib/widget.dart +++ b/webf/lib/widget.dart @@ -14,6 +14,7 @@ export 'src/widget/webf_element.dart'; export 'src/widget/event_listener.dart'; export 'src/widget/child_node_size.dart'; export 'src/widget/router_view.dart'; +export 'src/widget/global_root_view.dart'; export 'src/widget/contentful_widget_detector.dart'; export 'src/widget/nested_scroll_forwarder.dart'; export 'src/widget/ensure_visible.dart'; From 20361134721bff95d62a4f9c0db55f581b0c0192 Mon Sep 17 00:00:00 2001 From: "Dalton.m" Date: Tue, 14 Apr 2026 14:21:02 +0800 Subject: [PATCH 3/5] feat(use_cases): add global modal demo with webf-global-root --- use_cases/src/App.tsx | 12 +++++ use_cases/src/components/GlobalModal.tsx | 35 ++++++++++++ use_cases/src/hooks/useGlobalModal.ts | 23 ++++++++ use_cases/src/pages/GlobalModalDemoPage.tsx | 53 +++++++++++++++++++ use_cases/src/pages/HomePage.tsx | 1 + .../src/pages/globalModal/HelpSubPage.tsx | 41 ++++++++++++++ .../src/pages/globalModal/ProfileSubPage.tsx | 38 +++++++++++++ .../src/pages/globalModal/SettingsSubPage.tsx | 38 +++++++++++++ use_cases/src/react-app-env.d.ts | 8 ++- 9 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 use_cases/src/components/GlobalModal.tsx create mode 100644 use_cases/src/hooks/useGlobalModal.ts create mode 100644 use_cases/src/pages/GlobalModalDemoPage.tsx create mode 100644 use_cases/src/pages/globalModal/HelpSubPage.tsx create mode 100644 use_cases/src/pages/globalModal/ProfileSubPage.tsx create mode 100644 use_cases/src/pages/globalModal/SettingsSubPage.tsx diff --git a/use_cases/src/App.tsx b/use_cases/src/App.tsx index de1b0aa748..af92e0b4db 100644 --- a/use_cases/src/App.tsx +++ b/use_cases/src/App.tsx @@ -2,6 +2,7 @@ import React from 'react'; import './App.css'; import './main.css'; import { Routes, Route } from './router'; +import { GlobalModal } from './components/GlobalModal'; import {HomePage} from './pages/HomePage'; import { CookiesPage } from './pages/CookiesPage'; import { UrlEncodingPage } from './pages/UrlEncodingPage'; @@ -31,6 +32,10 @@ import { RoutingNotFoundPage } from './pages/RoutingNotFoundPage'; import { WebFRouterAPIDemoPage } from './pages/WebFRouterAPIDemoPage'; import {ContextMenuPage} from './pages/ContextMenuPage'; import {ModalPopupPage} from './pages/ModalPopupPage'; +import {GlobalModalDemoPage} from './pages/GlobalModalDemoPage'; +import {SettingsSubPage} from './pages/globalModal/SettingsSubPage'; +import {ProfileSubPage} from './pages/globalModal/ProfileSubPage'; +import {HelpSubPage} from './pages/globalModal/HelpSubPage'; import {LoadingPage} from './pages/LoadingPage'; import {AlertPage} from './pages/AlertPage'; import {ImagePreloadPage} from './pages/ImagePreloadPage'; @@ -149,6 +154,8 @@ function App() { return (
+ {/* Global root for overlays visible across all routes */} + {React.createElement('webf-global-root', null, React.createElement(GlobalModal))} }/> @@ -292,6 +299,10 @@ function App() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> {/*} />*/} @@ -307,6 +318,7 @@ function App() { {/*} />*/} {/*} />*/} {/*} />*/} +
); diff --git a/use_cases/src/components/GlobalModal.tsx b/use_cases/src/components/GlobalModal.tsx new file mode 100644 index 0000000000..f41f49c0b6 --- /dev/null +++ b/use_cases/src/components/GlobalModal.tsx @@ -0,0 +1,35 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { FlutterCupertinoModalPopup } from '@openwebf/react-cupertino-ui'; +import { registerGlobalModalListener, hideGlobalModal } from '../hooks/useGlobalModal'; + +export const GlobalModal: React.FC = () => { + const popupRef = useRef(null); + const [title, setTitle] = useState(''); + const [body, setBody] = useState(''); + + useEffect(() => { + const unregister = registerGlobalModalListener((payload) => { + if (payload) { + setTitle(payload.title); + setBody(payload.body); + popupRef.current?.show(); + } else { + popupRef.current?.hide(); + } + }); + return unregister; + }, []); + + return ( + hideGlobalModal()} + > +
+
{title}
+
{body}
+
+
+ ); +}; diff --git a/use_cases/src/hooks/useGlobalModal.ts b/use_cases/src/hooks/useGlobalModal.ts new file mode 100644 index 0000000000..8e688fbd41 --- /dev/null +++ b/use_cases/src/hooks/useGlobalModal.ts @@ -0,0 +1,23 @@ +// Global modal state using a simple callback pattern (no CustomEvent dependency) + +export interface GlobalModalPayload { + title: string; + body: string; +} + +type ModalListener = (payload: GlobalModalPayload | null) => void; + +let _listener: ModalListener | null = null; + +export function registerGlobalModalListener(listener: ModalListener) { + _listener = listener; + return () => { _listener = null; }; +} + +export function showGlobalModal(title: string, body: string) { + _listener?.({ title, body }); +} + +export function hideGlobalModal() { + _listener?.(null); +} diff --git a/use_cases/src/pages/GlobalModalDemoPage.tsx b/use_cases/src/pages/GlobalModalDemoPage.tsx new file mode 100644 index 0000000000..418ce31c2f --- /dev/null +++ b/use_cases/src/pages/GlobalModalDemoPage.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { WebFListView } from '@openwebf/react-core-ui'; +import { FlutterCupertinoButton } from '@openwebf/react-cupertino-ui'; +import { showGlobalModal } from '../hooks/useGlobalModal'; +import { WebFRouter } from '../router'; + +export const GlobalModalDemoPage: React.FC = () => { + return ( +
+ +

Global Modal Demo

+

+ The modal renders inside <webf-global-root> and stays visible across all sub-routes. +

+ +
+
+
Test from this page
+ showGlobalModal('Root Page Modal', 'This modal was triggered from the root /global-modal page.')}> + Show Modal Here + +
+ +
Navigate to sub-routes:
+ +
+
Settings Page
+
Has its own modal content
+ WebFRouter.pushState({}, '/global-modal/settings')}> + Go to Settings + +
+ +
+
Profile Page
+
Has its own modal content
+ WebFRouter.pushState({}, '/global-modal/profile')}> + Go to Profile + +
+ +
+
Help Page
+
Has its own modal content
+ WebFRouter.pushState({}, '/global-modal/help')}> + Go to Help + +
+
+
+
+ ); +}; diff --git a/use_cases/src/pages/HomePage.tsx b/use_cases/src/pages/HomePage.tsx index e7bd720aea..b7b2290f4b 100644 --- a/use_cases/src/pages/HomePage.tsx +++ b/use_cases/src/pages/HomePage.tsx @@ -21,6 +21,7 @@ const sections: Section[] = [ { label: 'WebFListView', path: '/listview' }, { label: 'Draggable List', path: '/dragable-list' }, { label: 'Routing', path: '/routing' }, + { label: 'Global Modal', path: '/global-modal' }, ], }, { diff --git a/use_cases/src/pages/globalModal/HelpSubPage.tsx b/use_cases/src/pages/globalModal/HelpSubPage.tsx new file mode 100644 index 0000000000..925404b31c --- /dev/null +++ b/use_cases/src/pages/globalModal/HelpSubPage.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { WebFListView } from '@openwebf/react-core-ui'; +import { FlutterCupertinoButton } from '@openwebf/react-cupertino-ui'; +import { showGlobalModal } from '../../hooks/useGlobalModal'; +import { WebFRouter } from '../../router'; + +export const HelpSubPage: React.FC = () => { + return ( +
+ +

Help & Support

+ +
+
+
FAQ
+
How do I use hybrid routing?
+
Use webf-router-link elements with path attributes.
+
+ +
+
What is webf-global-root?
+
A special element whose content renders above all routes.
+
+ +
+
Documentation
+
Visit openwebf.com for full docs.
+
+ + showGlobalModal('Contact Support', 'Email: support@openwebf.com\nDiscord: discord.gg/DvUBtXZ5rK\nGitHub: github.com/openwebf/webf\n\nWe typically respond within 24 hours.')}> + Contact Support (Show Modal) + + + WebFRouter.pop()}> + Back to Global Modal Demo + +
+
+
+ ); +}; diff --git a/use_cases/src/pages/globalModal/ProfileSubPage.tsx b/use_cases/src/pages/globalModal/ProfileSubPage.tsx new file mode 100644 index 0000000000..13c016e2b3 --- /dev/null +++ b/use_cases/src/pages/globalModal/ProfileSubPage.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { WebFListView } from '@openwebf/react-core-ui'; +import { FlutterCupertinoButton } from '@openwebf/react-cupertino-ui'; +import { showGlobalModal } from '../../hooks/useGlobalModal'; +import { WebFRouter } from '../../router'; + +export const ProfileSubPage: React.FC = () => { + return ( +
+ +

Profile

+ +
+
+ U +
+
User Name
+
user@example.com
+
+ +
+
+
Member since: Jan 2024
+
Plan: Pro
+
+ + showGlobalModal('Edit Profile', 'Name: User Name\nEmail: user@example.com\nPlan: Pro\nMember since: Jan 2024\n\nTap outside or close to dismiss.')}> + Edit Profile (Show Modal) + + + WebFRouter.pop()}> + Back to Global Modal Demo + +
+
+
+ ); +}; diff --git a/use_cases/src/pages/globalModal/SettingsSubPage.tsx b/use_cases/src/pages/globalModal/SettingsSubPage.tsx new file mode 100644 index 0000000000..d7ceb4cf76 --- /dev/null +++ b/use_cases/src/pages/globalModal/SettingsSubPage.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { WebFListView } from '@openwebf/react-core-ui'; +import { FlutterCupertinoButton, FlutterCupertinoSwitch } from '@openwebf/react-cupertino-ui'; +import { showGlobalModal } from '../../hooks/useGlobalModal'; +import { WebFRouter } from '../../router'; + +export const SettingsSubPage: React.FC = () => { + return ( +
+ +

Settings

+ +
+
+
Dark Mode
+ +
+
+
Notifications
+ +
+
+
Auto-save
+ +
+ + showGlobalModal('Settings Saved', 'All your settings have been saved successfully.\n\nDark Mode: ON\nNotifications: ON\nAuto-save: ON')}> + Save & Show Modal + + + WebFRouter.pop()}> + Back to Global Modal Demo + +
+
+
+ ); +}; diff --git a/use_cases/src/react-app-env.d.ts b/use_cases/src/react-app-env.d.ts index 6889c6b376..43cf4dcf7f 100644 --- a/use_cases/src/react-app-env.d.ts +++ b/use_cases/src/react-app-env.d.ts @@ -1 +1,7 @@ -/// \ No newline at end of file +/// + +declare namespace JSX { + interface IntrinsicElements { + 'webf-global-root': React.DetailedHTMLProps, HTMLElement>; + } +} \ No newline at end of file From a1cce3cf99c694e78ff7900246912ff5fb20fdc3 Mon Sep 17 00:00:00 2001 From: "Dalton.m" Date: Tue, 14 Apr 2026 14:25:02 +0800 Subject: [PATCH 4/5] fix(bridge): add skipLibCheck to code generator tsconfig --- bridge/scripts/code_generator/tsconfig.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bridge/scripts/code_generator/tsconfig.json b/bridge/scripts/code_generator/tsconfig.json index 9ebac78b8d..9214a68e46 100644 --- a/bridge/scripts/code_generator/tsconfig.json +++ b/bridge/scripts/code_generator/tsconfig.json @@ -20,6 +20,7 @@ "esModuleInterop": true, "allowSyntheticDefaultImports": true, "declaration": false, - "outDir": "./dist" + "outDir": "./dist", + "skipLibCheck": true } } From c46ae297d9960aa992db2b22c96668fedb8fb717 Mon Sep 17 00:00:00 2001 From: daltoncaoyuan Date: Thu, 7 May 2026 15:41:32 +0800 Subject: [PATCH 5/5] test(widget): add global-root stability and performance tests --- .../src/widget/global_root_perf_test.dart | 445 ++++++++++++ webf/test/src/widget/global_root_test.dart | 667 ++++++++++++++++++ 2 files changed, 1112 insertions(+) create mode 100644 webf/test/src/widget/global_root_perf_test.dart create mode 100644 webf/test/src/widget/global_root_test.dart diff --git a/webf/test/src/widget/global_root_perf_test.dart b/webf/test/src/widget/global_root_perf_test.dart new file mode 100644 index 0000000000..1502cae4d2 --- /dev/null +++ b/webf/test/src/widget/global_root_perf_test.dart @@ -0,0 +1,445 @@ +/* + * Copyright (C) 2024-present The OpenWebF Company. All rights reserved. + * Licensed under GNU GPL with Enterprise exception. + */ + +// ignore_for_file: avoid_print + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:webf/html.dart'; +import 'package:webf/webf.dart'; + +import '../../setup.dart'; +import 'test_utils.dart'; + +/// Performance thresholds (ms). Adjust if hardware differs significantly. +const int _kRegistrationThresholdMs = 50; +const int _kListenerNotifyThresholdMs = 5; +const int _kLargeChildrenThresholdMs = 500; +const int _kCycleThresholdMs = 200; +const int _kMultiListenerThresholdMs = 10; + +void main() { + setUpAll(() { + setupTest(); + }); + + setUp(() { + WebFControllerManager.instance.initialize( + WebFControllerManagerConfig( + maxAliveInstances: 5, + maxAttachedInstances: 5, + enableDevTools: false, + ), + ); + }); + + tearDown(() async { + await WebFControllerManager.instance.disposeAll(); + await Future.delayed(const Duration(milliseconds: 100)); + }); + + // --------------------------------------------------------------------------- + // 1. Registration / unregistration timing + // --------------------------------------------------------------------------- + group('GlobalRootElement - registration performance', () { + testWidgets('connectedCallback registers within ${_kRegistrationThresholdMs}ms', (tester) async { + // Measure time from DOM append to globalRoot being set. + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: '
', + ); + + final controller = prepared.controller; + final sw = Stopwatch()..start(); + + await tester.runAsync(() async { + await controller.view.evaluateJavaScripts(''' + var gr = document.createElement('webf-global-root'); + document.body.appendChild(gr); + '''); + controller.view.document.updateStyleIfNeeded(); + }); + await tester.pump(const Duration(milliseconds: 50)); + + sw.stop(); + expect(controller.view.globalRoot, isNotNull); + + print('[PERF] connectedCallback registration: ${sw.elapsedMilliseconds}ms'); + expect(sw.elapsedMilliseconds, lessThan(_kRegistrationThresholdMs), + reason: 'Registration should complete within ${_kRegistrationThresholdMs}ms'); + }); + + testWidgets('disconnectedCallback unregisters within ${_kRegistrationThresholdMs}ms', (tester) async { + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: '', + ); + + final controller = prepared.controller; + expect(controller.view.globalRoot, isNotNull); + + final sw = Stopwatch()..start(); + + await tester.runAsync(() async { + await controller.view.evaluateJavaScripts( + 'document.getElementById("gr").remove();'); + controller.view.document.updateStyleIfNeeded(); + }); + await tester.pump(const Duration(milliseconds: 50)); + + sw.stop(); + expect(controller.view.globalRoot, isNull); + + print('[PERF] disconnectedCallback unregistration: ${sw.elapsedMilliseconds}ms'); + expect(sw.elapsedMilliseconds, lessThan(_kRegistrationThresholdMs), + reason: 'Unregistration should complete within ${_kRegistrationThresholdMs}ms'); + }); + + testWidgets('10 consecutive replacements complete within ${_kCycleThresholdMs}ms total', (tester) async { + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: '', + ); + + final controller = prepared.controller; + final sw = Stopwatch()..start(); + + for (int i = 1; i <= 10; i++) { + await tester.runAsync(() async { + await controller.view.evaluateJavaScripts(''' + var old = document.querySelector('webf-global-root'); + if (old) old.remove(); + var gr = document.createElement('webf-global-root'); + gr.id = 'gr$i'; + document.body.appendChild(gr); + '''); + controller.view.document.updateStyleIfNeeded(); + }); + await tester.pump(const Duration(milliseconds: 10)); + } + + sw.stop(); + expect(controller.view.globalRoot, isNotNull); + + print('[PERF] 10 consecutive replacements: ${sw.elapsedMilliseconds}ms total'); + expect(sw.elapsedMilliseconds, lessThan(_kCycleThresholdMs), + reason: '10 replacements should complete within ${_kCycleThresholdMs}ms'); + }); + }); + + // --------------------------------------------------------------------------- + // 2. Listener notification performance + // --------------------------------------------------------------------------- + group('view_controller - listener notification performance', () { + testWidgets('single listener notified within ${_kListenerNotifyThresholdMs}ms', (tester) async { + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: '
no global root
', + ); + + final controller = prepared.controller; + int notifyCount = 0; + final sw = Stopwatch(); + + controller.view.addGlobalRootListener(() { + sw.stop(); + notifyCount++; + }); + + sw.start(); + await tester.runAsync(() async { + await controller.view.evaluateJavaScripts(''' + var gr = document.createElement('webf-global-root'); + document.body.appendChild(gr); + '''); + controller.view.document.updateStyleIfNeeded(); + }); + await tester.pump(const Duration(milliseconds: 50)); + + expect(notifyCount, greaterThan(0)); + print('[PERF] single listener notification latency: ${sw.elapsedMilliseconds}ms'); + expect(sw.elapsedMilliseconds, lessThan(_kListenerNotifyThresholdMs), + reason: 'Listener should be notified within ${_kListenerNotifyThresholdMs}ms'); + }); + + testWidgets('50 listeners all notified within ${_kMultiListenerThresholdMs}ms', (tester) async { + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: '
no global root
', + ); + + final controller = prepared.controller; + int notifyCount = 0; + final listeners = []; + + for (int i = 0; i < 50; i++) { + void l() => notifyCount++; + listeners.add(l); + controller.view.addGlobalRootListener(l); + } + + final sw = Stopwatch()..start(); + + await tester.runAsync(() async { + await controller.view.evaluateJavaScripts(''' + var gr = document.createElement('webf-global-root'); + document.body.appendChild(gr); + '''); + controller.view.document.updateStyleIfNeeded(); + }); + await tester.pump(const Duration(milliseconds: 50)); + + sw.stop(); + + expect(notifyCount, equals(50)); + print('[PERF] 50 listeners notification: ${sw.elapsedMilliseconds}ms'); + expect(sw.elapsedMilliseconds, lessThan(_kMultiListenerThresholdMs), + reason: '50 listeners should all be notified within ${_kMultiListenerThresholdMs}ms'); + + for (final l in listeners) { + controller.view.removeGlobalRootListener(l); + } + }); + + testWidgets('add/remove 100 listeners has no memory leak (count stays 0 after remove)', (tester) async { + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: '
no global root
', + ); + + final controller = prepared.controller; + int notifyCount = 0; + + // Add and immediately remove 100 listeners + for (int i = 0; i < 100; i++) { + void l() => notifyCount++; + controller.view.addGlobalRootListener(l); + controller.view.removeGlobalRootListener(l); + } + + await tester.runAsync(() async { + await controller.view.evaluateJavaScripts(''' + var gr = document.createElement('webf-global-root'); + document.body.appendChild(gr); + '''); + controller.view.document.updateStyleIfNeeded(); + }); + await tester.pump(const Duration(milliseconds: 50)); + + expect(notifyCount, equals(0), + reason: 'All removed listeners should not be notified — no leak'); + }); + }); + + // --------------------------------------------------------------------------- + // 3. Child rendering performance + // --------------------------------------------------------------------------- + group('GlobalRootElement - child rendering performance', () { + testWidgets('50 normal-flow children render within ${_kLargeChildrenThresholdMs}ms', (tester) async { + final children = List.generate( + 50, + (i) => '
item $i
', + ).join(''); + + final sw = Stopwatch()..start(); + + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: '$children', + ); + + sw.stop(); + + // Verify all children exist + for (int i = 0; i < 50; i++) { + final child = prepared.controller.view.document.getElementById(['perf-child-$i']); + expect(child, isNotNull, reason: 'perf-child-$i should exist'); + } + + print('[PERF] 50 children initial render: ${sw.elapsedMilliseconds}ms'); + expect(sw.elapsedMilliseconds, lessThan(_kLargeChildrenThresholdMs), + reason: '50 children should render within ${_kLargeChildrenThresholdMs}ms'); + }); + + testWidgets('100 children appended dynamically within ${_kLargeChildrenThresholdMs}ms', (tester) async { + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: '', + ); + + final controller = prepared.controller; + final sw = Stopwatch()..start(); + + await tester.runAsync(() async { + // Append 100 children in a single JS call for efficiency + await controller.view.evaluateJavaScripts(''' + var gr = document.getElementById('gr'); + var fragment = document.createDocumentFragment(); + for (var i = 0; i < 100; i++) { + var div = document.createElement('div'); + div.id = 'dyn-child-' + i; + div.style.height = '10px'; + fragment.appendChild(div); + } + gr.appendChild(fragment); + '''); + controller.view.document.updateStyleIfNeeded(); + }); + await tester.pump(const Duration(milliseconds: 100)); + + sw.stop(); + + // Spot-check a few children + expect(controller.view.document.getElementById(['dyn-child-0']), isNotNull); + expect(controller.view.document.getElementById(['dyn-child-99']), isNotNull); + + print('[PERF] 100 dynamic children append: ${sw.elapsedMilliseconds}ms'); + expect(sw.elapsedMilliseconds, lessThan(_kLargeChildrenThresholdMs), + reason: '100 dynamic children should append within ${_kLargeChildrenThresholdMs}ms'); + }); + + testWidgets('20 mixed-position children render within ${_kLargeChildrenThresholdMs}ms', (tester) async { + // Mix of normal, fixed, absolute, sticky — exercises all PositionPlaceHolder paths + final children = List.generate(20, (i) { + final positions = ['static', 'fixed', 'absolute', 'sticky']; + final pos = positions[i % 4]; + final extra = pos == 'sticky' ? 'top:0;' : pos == 'fixed' ? 'top:${i * 5}px;left:0;' : ''; + return '
$i
'; + }).join(''); + + final sw = Stopwatch()..start(); + + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: '$children', + ); + + sw.stop(); + + expect(prepared.controller.view.globalRoot, isNotNull); + print('[PERF] 20 mixed-position children render: ${sw.elapsedMilliseconds}ms'); + expect(sw.elapsedMilliseconds, lessThan(_kLargeChildrenThresholdMs), + reason: '20 mixed-position children should render within ${_kLargeChildrenThresholdMs}ms'); + }); + }); + + // --------------------------------------------------------------------------- + // 4. High-frequency add/remove stability + // --------------------------------------------------------------------------- + group('GlobalRootElement - high-frequency stability', () { + testWidgets('50 rapid add/remove cycles complete within ${_kCycleThresholdMs * 3}ms', (tester) async { + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: '
', + ); + + final controller = prepared.controller; + final sw = Stopwatch()..start(); + + for (int i = 0; i < 50; i++) { + await tester.runAsync(() async { + await controller.view.evaluateJavaScripts(''' + var existing = document.querySelector('webf-global-root'); + if (existing) existing.remove(); + var gr = document.createElement('webf-global-root'); + document.body.appendChild(gr); + '''); + controller.view.document.updateStyleIfNeeded(); + }); + // Minimal pump — just enough to process microtasks + await tester.pump(const Duration(milliseconds: 5)); + } + + sw.stop(); + expect(controller.view.globalRoot, isNotNull); + + print('[PERF] 50 rapid add/remove cycles: ${sw.elapsedMilliseconds}ms total'); + expect(sw.elapsedMilliseconds, lessThan(_kCycleThresholdMs * 3), + reason: '50 cycles should complete within ${_kCycleThresholdMs * 3}ms'); + }); + + testWidgets('repeated setGlobalRoot/removeGlobalRoot does not grow listener list', (tester) async { + // Verify that the internal listener list does not grow unboundedly. + // Each cycle does: remove (→ removeGlobalRoot notifies) + append (→ setGlobalRoot notifies). + // So each cycle fires our listener exactly 2 times (or 1 on cycle 0 when there's nothing to remove). + // The key invariant: the count must be IDENTICAL across all cycles after the first. + // If the listener list grew, later cycles would show higher counts. + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: '
no global root
', + ); + + final controller = prepared.controller; + final notifyCounts = []; + + for (int cycle = 0; cycle < 5; cycle++) { + int count = 0; + void listener() => count++; + controller.view.addGlobalRootListener(listener); + + await tester.runAsync(() async { + await controller.view.evaluateJavaScripts(''' + var existing = document.querySelector('webf-global-root'); + if (existing) existing.remove(); + var gr = document.createElement('webf-global-root'); + document.body.appendChild(gr); + '''); + controller.view.document.updateStyleIfNeeded(); + }); + await tester.pump(const Duration(milliseconds: 30)); + + notifyCounts.add(count); + controller.view.removeGlobalRootListener(listener); + } + + print('[PERF] listener counts per cycle: $notifyCounts'); + + // cycle 0: no existing element → only setGlobalRoot fires → count == 1 + // cycle 1+: remove (removeGlobalRoot) + append (setGlobalRoot) → count == 2 + // If the listener list leaked, later cycles would show counts > 2. + expect(notifyCounts[0], equals(1), + reason: 'Cycle 0 has no prior element to remove, so only 1 notification'); + for (int i = 1; i < notifyCounts.length; i++) { + expect(notifyCounts[i], equals(2), + reason: 'Cycle $i should notify exactly 2 times (remove + set) — no listener leak'); + } + }); + }); + + // --------------------------------------------------------------------------- + // 5. WebFGlobalRootView rebuild performance + // --------------------------------------------------------------------------- + group('WebFGlobalRootView - rebuild performance', () { + testWidgets('globalRoot set by listener within one pump frame', (tester) async { + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: '
', + ); + + final controller = prepared.controller; + expect(controller.view.globalRoot, isNull); + + // Add global root while WebF tree is still mounted + await tester.runAsync(() async { + await controller.view.evaluateJavaScripts(''' + var gr = document.createElement('webf-global-root'); + document.body.appendChild(gr); + '''); + controller.view.document.updateStyleIfNeeded(); + }); + + final sw = Stopwatch()..start(); + await tester.pump(const Duration(milliseconds: 16)); // ~1 frame at 60fps + sw.stop(); + + // globalRoot must be set — connectedCallback + listener fired correctly + expect(controller.view.globalRoot, isNotNull); + + print('[PERF] globalRoot set after one pump frame: ${sw.elapsedMilliseconds}ms'); + expect(sw.elapsedMilliseconds, lessThan(100), + reason: 'globalRoot should be set within one frame budget'); + }); + }); +} diff --git a/webf/test/src/widget/global_root_test.dart b/webf/test/src/widget/global_root_test.dart new file mode 100644 index 0000000000..de7067b999 --- /dev/null +++ b/webf/test/src/widget/global_root_test.dart @@ -0,0 +1,667 @@ +/* + * Copyright (C) 2024-present The OpenWebF Company. All rights reserved. + * Licensed under GNU GPL with Enterprise exception. + */ + +import 'dart:ui' as ui; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:webf/css.dart'; +import 'package:webf/html.dart'; +import 'package:webf/webf.dart'; + +import '../../setup.dart'; +import 'test_utils.dart'; + +/// Wraps [widget] in a [Directionality] so widgets like [Stack] that require +/// a text-direction ancestor work correctly in unit tests. +Widget _withDirectionality(Widget widget) { + return Directionality( + textDirection: TextDirection.ltr, + child: widget, + ); +} + +void main() { + setUpAll(() { + setupTest(); + }); + + setUp(() { + WebFControllerManager.instance.initialize( + WebFControllerManagerConfig( + maxAliveInstances: 5, + maxAttachedInstances: 5, + enableDevTools: false, + ), + ); + }); + + tearDown(() async { + await WebFControllerManager.instance.disposeAll(); + await Future.delayed(const Duration(milliseconds: 100)); + }); + // --------------------------------------------------------------------------- + // 1. Registration lifecycle + // --------------------------------------------------------------------------- + group('GlobalRootElement - registration lifecycle', () { + testWidgets('registers with view when connected to DOM', (tester) async { + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: '', + ); + + expect(prepared.controller.view.globalRoot, isNotNull, + reason: 'globalRoot should be set after element connects'); + expect(prepared.controller.view.globalRoot, isA()); + }); + + testWidgets('unregisters from view when removed from DOM', (tester) async { + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: '', + ); + + final controller = prepared.controller; + expect(controller.view.globalRoot, isNotNull); + + await tester.runAsync(() async { + await controller.view.evaluateJavaScripts( + 'document.getElementById("gr").remove();'); + controller.view.document.updateStyleIfNeeded(); + }); + await tester.pump(const Duration(milliseconds: 100)); + + expect(controller.view.globalRoot, isNull, + reason: 'globalRoot should be null after element is removed'); + }); + + testWidgets('second webf-global-root replaces the first', (tester) async { + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: '', + ); + + final controller = prepared.controller; + final first = controller.view.globalRoot; + expect(first, isNotNull); + + await tester.runAsync(() async { + await controller.view.evaluateJavaScripts(''' + var gr2 = document.createElement('webf-global-root'); + gr2.id = 'gr2'; + document.body.appendChild(gr2); + '''); + controller.view.document.updateStyleIfNeeded(); + }); + await tester.pump(const Duration(milliseconds: 100)); + + final second = controller.view.globalRoot; + expect(second, isNotNull); + expect(second, isNot(same(first)), + reason: 'second global root should replace the first'); + }); + + testWidgets('globalRoot is null when no webf-global-root in DOM', (tester) async { + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: '
no global root here
', + ); + + expect(prepared.controller.view.globalRoot, isNull); + }); + + testWidgets('re-adding element after removal re-registers', (tester) async { + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: '', + ); + + final controller = prepared.controller; + + // Remove + await tester.runAsync(() async { + await controller.view.evaluateJavaScripts( + 'document.getElementById("gr").remove();'); + controller.view.document.updateStyleIfNeeded(); + }); + await tester.pump(const Duration(milliseconds: 100)); + expect(controller.view.globalRoot, isNull); + + // Re-add + await tester.runAsync(() async { + await controller.view.evaluateJavaScripts(''' + var gr = document.createElement('webf-global-root'); + document.body.appendChild(gr); + '''); + controller.view.document.updateStyleIfNeeded(); + }); + await tester.pump(const Duration(milliseconds: 100)); + expect(controller.view.globalRoot, isNotNull, + reason: 'globalRoot should be re-registered after re-adding element'); + }); + }); + + // --------------------------------------------------------------------------- + // 2. WebFGlobalRootView widget rendering + // --------------------------------------------------------------------------- + group('WebFGlobalRootView - widget rendering', () { + testWidgets('renders SizedBox.shrink when globalRoot is null', (tester) async { + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: '
no global root
', + ); + + // Verify controller state + expect(prepared.controller.view.globalRoot, isNull); + + // Render WebFGlobalRootView standalone (controller has no globalRoot) + // — must pumpWidget after prepareWidgetTest has finished + final view = WebFGlobalRootView(controller: prepared.controller); + await tester.pumpWidget(_withDirectionality(view)); + await tester.pump(); + + expect(find.byType(WebFGlobalRootView), findsOneWidget); + expect(find.byType(WebFRouterViewport), findsNothing); + }); + + testWidgets('renders WebFRouterViewport when globalRoot is set', (tester) async { + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: '
overlay
', + ); + + // globalRoot is already set by the time prepareWidgetTest completes + expect(prepared.controller.view.globalRoot, isNotNull); + + final view = WebFGlobalRootView(controller: prepared.controller); + await tester.pumpWidget(_withDirectionality(view)); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.byType(WebFRouterViewport), findsOneWidget); + }); + + testWidgets('rebuilds when globalRoot is dynamically added', (tester) async { + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: '
', + ); + + final controller = prepared.controller; + expect(controller.view.globalRoot, isNull); + + // Add global root dynamically — DOM op runs while WebF tree is still mounted + await tester.runAsync(() async { + await controller.view.evaluateJavaScripts(''' + var gr = document.createElement('webf-global-root'); + document.body.appendChild(gr); + '''); + controller.view.document.updateStyleIfNeeded(); + }); + await tester.pump(const Duration(milliseconds: 100)); + + // Verify via controller state — listener fired synchronously + expect(controller.view.globalRoot, isNotNull, + reason: 'globalRoot should be set after dynamic append'); + + // Now render WebFGlobalRootView to confirm it shows the viewport + final view = WebFGlobalRootView(controller: controller); + await tester.pumpWidget(_withDirectionality(view)); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.byType(WebFRouterViewport), findsOneWidget); + }); + + testWidgets('collapses back to SizedBox.shrink when globalRoot is removed', (tester) async { + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: '
overlay
', + ); + + final controller = prepared.controller; + expect(controller.view.globalRoot, isNotNull); + + // Remove global root while WebF tree is still mounted + await tester.runAsync(() async { + await controller.view.evaluateJavaScripts( + 'document.getElementById("gr").remove();'); + controller.view.document.updateStyleIfNeeded(); + }); + await tester.pump(const Duration(milliseconds: 100)); + + // Verify controller state — removeGlobalRoot was called synchronously + expect(controller.view.globalRoot, isNull, + reason: 'globalRoot should be null after element removed'); + + // Now render WebFGlobalRootView — globalRoot is null so it shows SizedBox.shrink + final view = WebFGlobalRootView(controller: controller); + await tester.pumpWidget(_withDirectionality(view)); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.byType(WebFRouterViewport), findsNothing, + reason: 'WebFGlobalRootView should render nothing when globalRoot is null'); + }); + + testWidgets('listener is cleaned up on dispose — no setState after unmount', (tester) async { + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: '', + ); + + final controller = prepared.controller; + final gr = controller.view.globalRoot! as GlobalRootElement; + + await tester.pumpWidget(_withDirectionality(WebFGlobalRootView(controller: controller))); + await tester.pump(); + + // Dispose the widget + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + + // Trigger a globalRoot change after dispose — should not throw + expect( + () => controller.view.removeGlobalRoot(gr), + returnsNormally, + ); + }); + }); + + // --------------------------------------------------------------------------- + // 3. Child rendering — normal flow and positioned + // --------------------------------------------------------------------------- + group('GlobalRootElement - child rendering', () { + testWidgets('renders normal flow children with correct dimensions', (tester) async { + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: ''' + +
hello
+
+ ''', + ); + + final child = prepared.getElementById('child'); + expect(child.offsetWidth, equals(100.0)); + expect(child.offsetHeight, equals(50.0)); + }); + + testWidgets('renders fixed-position children', (tester) async { + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: ''' + +
+ fixed overlay +
+
+ ''', + ); + + final child = prepared.getElementById('fixed-child'); + expect(child.renderStyle.position, equals(CSSPositionType.fixed)); + }); + + testWidgets('renders absolute-position children', (tester) async { + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: ''' + +
+ absolute overlay +
+
+ ''', + ); + + final child = prepared.getElementById('abs-child'); + expect(child.renderStyle.position, equals(CSSPositionType.absolute)); + }); + + testWidgets('renders sticky-position children', (tester) async { + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: ''' + +
+ sticky header +
+
+ ''', + ); + + final child = prepared.getElementById('sticky-child'); + expect(child.renderStyle.position, equals(CSSPositionType.sticky)); + }); + + testWidgets('dynamically added children appear in DOM', (tester) async { + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: '', + ); + + final controller = prepared.controller; + + await tester.runAsync(() async { + await controller.view.evaluateJavaScripts(''' + var child = document.createElement('div'); + child.id = 'dynamic-child'; + child.style.width = '100px'; + child.style.height = '50px'; + document.getElementById('gr').appendChild(child); + '''); + controller.view.document.updateStyleIfNeeded(); + }); + await tester.pump(const Duration(milliseconds: 100)); + + final child = controller.view.document.getElementById(['dynamic-child']); + expect(child, isNotNull); + }); + + testWidgets('dynamically removed children are gone from DOM', (tester) async { + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: ''' + +
to be removed
+
+ ''', + ); + + final controller = prepared.controller; + expect(controller.view.document.getElementById(['removable']), isNotNull); + + await tester.runAsync(() async { + await controller.view.evaluateJavaScripts( + 'document.getElementById("removable").remove();'); + controller.view.document.updateStyleIfNeeded(); + }); + await tester.pump(const Duration(milliseconds: 100)); + + expect(controller.view.document.getElementById(['removable']), isNull); + }); + + testWidgets('empty webf-global-root renders without error', (tester) async { + await expectLater( + () => WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: '', + ), + returnsNormally, + ); + }); + + testWidgets('webf-global-root with many children renders all of them', (tester) async { + final children = List.generate( + 20, + (i) => '
item $i
', + ).join(''); + + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: '$children', + ); + + for (int i = 0; i < 20; i++) { + final child = prepared.controller.view.document.getElementById(['child-$i']); + expect(child, isNotNull, reason: 'child-$i should exist'); + } + }); + }); + + // --------------------------------------------------------------------------- + // 4. Key stability + // --------------------------------------------------------------------------- + group('GlobalRootElement - key stability', () { + testWidgets('always uses the fixed ValueKey WEBF_GLOBAL_ROOT', (tester) async { + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: '', + ); + + final gr = prepared.controller.view.globalRoot as GlobalRootElement; + expect(gr.key, equals(const ValueKey('WEBF_GLOBAL_ROOT'))); + }); + }); + + // --------------------------------------------------------------------------- + // 5. view_controller listener management + // --------------------------------------------------------------------------- + group('view_controller - listener management', () { + testWidgets('multiple listeners are all notified on setGlobalRoot', (tester) async { + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: '
no global root yet
', + ); + + final controller = prepared.controller; + int notifyCount = 0; + + void listener1() => notifyCount++; + void listener2() => notifyCount++; + + controller.view.addGlobalRootListener(listener1); + controller.view.addGlobalRootListener(listener2); + + await tester.runAsync(() async { + await controller.view.evaluateJavaScripts(''' + var gr = document.createElement('webf-global-root'); + document.body.appendChild(gr); + '''); + controller.view.document.updateStyleIfNeeded(); + }); + await tester.pump(const Duration(milliseconds: 100)); + + expect(notifyCount, equals(2), + reason: 'both listeners should be notified'); + + controller.view.removeGlobalRootListener(listener1); + controller.view.removeGlobalRootListener(listener2); + }); + + testWidgets('removed listener is not notified', (tester) async { + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: '
no global root yet
', + ); + + final controller = prepared.controller; + int notifyCount = 0; + void listener() => notifyCount++; + + controller.view.addGlobalRootListener(listener); + controller.view.removeGlobalRootListener(listener); + + await tester.runAsync(() async { + await controller.view.evaluateJavaScripts(''' + var gr = document.createElement('webf-global-root'); + document.body.appendChild(gr); + '''); + controller.view.document.updateStyleIfNeeded(); + }); + await tester.pump(const Duration(milliseconds: 100)); + + expect(notifyCount, equals(0), + reason: 'removed listener should not be notified'); + }); + + testWidgets('removeGlobalRoot is a no-op for non-matching element', (tester) async { + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: '', + ); + + final controller = prepared.controller; + final original = controller.view.globalRoot!; + + // Add a second global root (it will replace the first via setGlobalRoot), + // then remove the original — the second should remain. + await tester.runAsync(() async { + await controller.view.evaluateJavaScripts(''' + var gr2 = document.createElement('webf-global-root'); + gr2.id = 'gr2'; + document.body.appendChild(gr2); + '''); + controller.view.document.updateStyleIfNeeded(); + }); + await tester.pump(const Duration(milliseconds: 100)); + + final second = controller.view.globalRoot!; + expect(second, isNot(same(original))); + + // Manually call removeGlobalRoot with the original (already replaced) element. + // Since globalRoot is now `second`, this should be a no-op. + controller.view.removeGlobalRoot(original as GlobalRootElement); + + expect(controller.view.globalRoot, same(second), + reason: 'removeGlobalRoot should only remove the matching element'); + }); + + testWidgets('listeners are notified on removeGlobalRoot', (tester) async { + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: '', + ); + + final controller = prepared.controller; + int notifyCount = 0; + void listener() => notifyCount++; + controller.view.addGlobalRootListener(listener); + + await tester.runAsync(() async { + await controller.view.evaluateJavaScripts( + 'document.getElementById("gr").remove();'); + controller.view.document.updateStyleIfNeeded(); + }); + await tester.pump(const Duration(milliseconds: 100)); + + expect(notifyCount, greaterThan(0), + reason: 'listeners should be notified when globalRoot is removed'); + + controller.view.removeGlobalRootListener(listener); + }); + }); + + // --------------------------------------------------------------------------- + // 6. WebFGlobalRootView - controller swap + // --------------------------------------------------------------------------- + group('WebFGlobalRootView - controller swap', () { + testWidgets('updates listener when controller changes', (tester) async { + tester.view.physicalSize = const ui.Size(360, 640); + tester.view.devicePixelRatio = 1; + + final ts = DateTime.now().millisecondsSinceEpoch; + final name1 = 'ctrl-swap-1-$ts'; + final name2 = 'ctrl-swap-2-$ts'; + + WebFController? ctrl1, ctrl2; + + await tester.runAsync(() async { + ctrl1 = await WebFControllerManager.instance.addWithPreload( + name: name1, + createController: () => + WebFController(viewportWidth: 360, viewportHeight: 640), + bundle: WebFBundle.fromContent( + '', + url: 'test://$name1/', + contentType: htmlContentType, + ), + ); + ctrl2 = await WebFControllerManager.instance.addWithPreload( + name: name2, + createController: () => + WebFController(viewportWidth: 360, viewportHeight: 640), + bundle: WebFBundle.fromContent( + '
no global root
', + url: 'test://$name2/', + contentType: htmlContentType, + ), + ); + await Future.wait([ + ctrl1!.controlledInitCompleter.future, + ctrl2!.controlledInitCompleter.future, + ]); + }); + + // Mount with ctrl1 (has global root) + await tester.pumpWidget(_withDirectionality(WebFGlobalRootView(controller: ctrl1!))); + await tester.pump(const Duration(milliseconds: 200)); + expect(ctrl1!.view.globalRoot, isNotNull); + + // Swap to ctrl2 (no global root) — should not throw + await tester.pumpWidget(_withDirectionality(WebFGlobalRootView(controller: ctrl2!))); + await tester.pump(const Duration(milliseconds: 100)); + expect(ctrl2!.view.globalRoot, isNull); + // Old controller's listener should have been removed — no crash + }); + }); + + // --------------------------------------------------------------------------- + // 7. Stress / edge cases + // --------------------------------------------------------------------------- + group('GlobalRootElement - stress and edge cases', () { + testWidgets('rapid add/remove cycles do not crash', (tester) async { + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: '
', + ); + + final controller = prepared.controller; + + for (int i = 0; i < 10; i++) { + await tester.runAsync(() async { + await controller.view.evaluateJavaScripts(''' + var existing = document.querySelector('webf-global-root'); + if (existing) existing.remove(); + var gr = document.createElement('webf-global-root'); + document.body.appendChild(gr); + '''); + controller.view.document.updateStyleIfNeeded(); + }); + await tester.pump(const Duration(milliseconds: 30)); + } + + // After rapid cycling, a valid global root should still be registered + expect(controller.view.globalRoot, isNotNull); + }); + + testWidgets('mixed positioned and normal children render without error', (tester) async { + await expectLater( + () => WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: ''' + +
normal
+
fixed
+
abs
+
sticky
+
+ ''', + ), + returnsNormally, + ); + }); + + testWidgets('webf-global-root nested inside another element still registers', (tester) async { + // The element should register regardless of where it sits in the DOM tree + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: ''' +
+ +
+ ''', + ); + + expect(prepared.controller.view.globalRoot, isNotNull, + reason: 'globalRoot should register even when nested inside another element'); + }); + + testWidgets('defaultStyle has display:block', (tester) async { + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + html: '', + ); + + final gr = prepared.controller.view.globalRoot as GlobalRootElement; + expect(gr.defaultStyle['display'], equals('block')); + }); + }); +}