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
}
}
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/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()}
+ >
+
+
+ );
+};
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
+
+
+
+
+
+
+
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
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:
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';
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'));
+ });
+ });
+}