From c4309885374b71f410e985a88078aeb21cfc8ba6 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Fri, 2 Oct 2026 18:40:29 -0700 Subject: [PATCH 01/15] Add shared native-core modern component foundations and validation handoff Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/native-core-modern-preview.md | 8 + apps/storybook/storybook.config.mts | 5 +- packages/agentic/native-core/AGENTS.md | 34 + packages/agentic/native-core/README.md | 41 +- .../Callout/fabric/RCTCalloutComponentView.mm | 58 +- .../Callout/paper/FRNCalloutManager.m | 45 +- .../Callout/shared/CalloutPlacement.swift | 36 + .../Callout/shared/CalloutView.swift | 720 +++++++----------- .../Callout/shared/CalloutWindow.swift | 3 +- .../fabric/RCTFocusZoneComponentView.mm | 37 +- .../FocusZone/paper/RCTFocusZoneManager.m | 17 + .../FocusZone/shared/RCTFocusZone.h | 4 + .../FocusZone/shared/RCTFocusZone.m | 9 + .../shared/FRNNativeCoreComponentLookup.h | 7 + .../shared/FRNNativeCoreComponentLookup.mm | 15 + .../shared/FRNNativeCoreFocusService.swift | 69 ++ .../native-core/plans/modernization.md | 210 +++++ .../components/callout/Callout.stories.tsx | 147 ++++ .../src/components/callout/Callout.tsx | 193 +++++ .../src/components/callout/Callout.types.ts | 48 ++ .../src/components/callout/SPEC.md | 31 + .../focus-zone/FocusZone.stories.tsx | 114 +++ .../src/components/focus-zone/FocusZone.tsx | 82 ++ .../components/focus-zone/FocusZone.types.ts | 22 + .../src/components/focus-zone/SPEC.md | 24 + .../src/components/modern.test.tsx | 192 +++++ .../src/components/modern.types.test.ts | 24 + .../src/components/nativeOperation.test.ts | 65 ++ .../src/components/nativeOperation.ts | 124 +++ .../src/components/nativeTarget.ts | 104 +++ .../native-core/src/internal/CalloutHost.tsx | 131 ++++ .../src/internal/FocusZoneHost.tsx | 68 ++ .../src/internal/focusZoneCommands.ts | 3 + .../src/internal/focusZoneCommands.win32.ts | 13 + .../native-core/src/internal/modernHost.ts | 11 + .../src/legacy/callout/Callout.tsx | 106 +-- .../src/legacy/focus-zone/FocusZone.tsx | 46 +- .../agentic/native-core/src/macos/index.ts | 16 +- .../components/CalloutNativeComponent.ts | 10 +- .../components/FocusZoneNativeComponent.ts | 17 +- .../agentic/native-core/src/windows/index.ts | 16 +- .../agentic/native-core/tsconfig.stories.json | 2 +- .../windows/NativeCore/NativeCore.vcxproj | 1 + .../components/FRNNativeCoreSpec/Callout.g.h | 102 +++ .../FRNNativeCoreSpec/FocusZone.g.h | 53 +- .../NativeCore/components/Callout/Callout.cpp | 132 +++- .../FocusZone/FocusZoneComponentView.cpp | 19 + .../windows/NativeCore/shared/FocusRequest.h | 52 ++ 48 files changed, 2636 insertions(+), 650 deletions(-) create mode 100644 .changeset/native-core-modern-preview.md create mode 100644 packages/agentic/native-core/AGENTS.md create mode 100644 packages/agentic/native-core/macos/components/Callout/shared/CalloutPlacement.swift create mode 100644 packages/agentic/native-core/macos/shared/FRNNativeCoreComponentLookup.h create mode 100644 packages/agentic/native-core/macos/shared/FRNNativeCoreComponentLookup.mm create mode 100644 packages/agentic/native-core/macos/shared/FRNNativeCoreFocusService.swift create mode 100644 packages/agentic/native-core/plans/modernization.md create mode 100644 packages/agentic/native-core/src/components/callout/Callout.stories.tsx create mode 100644 packages/agentic/native-core/src/components/callout/Callout.tsx create mode 100644 packages/agentic/native-core/src/components/callout/Callout.types.ts create mode 100644 packages/agentic/native-core/src/components/callout/SPEC.md create mode 100644 packages/agentic/native-core/src/components/focus-zone/FocusZone.stories.tsx create mode 100644 packages/agentic/native-core/src/components/focus-zone/FocusZone.tsx create mode 100644 packages/agentic/native-core/src/components/focus-zone/FocusZone.types.ts create mode 100644 packages/agentic/native-core/src/components/focus-zone/SPEC.md create mode 100644 packages/agentic/native-core/src/components/modern.test.tsx create mode 100644 packages/agentic/native-core/src/components/modern.types.test.ts create mode 100644 packages/agentic/native-core/src/components/nativeOperation.test.ts create mode 100644 packages/agentic/native-core/src/components/nativeOperation.ts create mode 100644 packages/agentic/native-core/src/components/nativeTarget.ts create mode 100644 packages/agentic/native-core/src/internal/CalloutHost.tsx create mode 100644 packages/agentic/native-core/src/internal/FocusZoneHost.tsx create mode 100644 packages/agentic/native-core/src/internal/focusZoneCommands.ts create mode 100644 packages/agentic/native-core/src/internal/focusZoneCommands.win32.ts create mode 100644 packages/agentic/native-core/src/internal/modernHost.ts create mode 100644 packages/agentic/native-core/windows/NativeCore/shared/FocusRequest.h diff --git a/.changeset/native-core-modern-preview.md b/.changeset/native-core-modern-preview.md new file mode 100644 index 00000000000..1c3988d44b3 --- /dev/null +++ b/.changeset/native-core-modern-preview.md @@ -0,0 +1,8 @@ +--- +'@fluentui-react-native/native-core': minor +--- + +Add platform-scoped React 19 Callout and FocusZone previews with live targets, +confirmed cancellable native commands, shared legacy host adapters, Swift +macOS focus/placement services, and a cross-machine validation plan. Preserve +Win32 legacy registrations without new managed transport. diff --git a/apps/storybook/storybook.config.mts b/apps/storybook/storybook.config.mts index 62844258996..346b71d5497 100644 --- a/apps/storybook/storybook.config.mts +++ b/apps/storybook/storybook.config.mts @@ -33,7 +33,10 @@ export default makeDesktopStorybookConfig({ { platformSettings: { windows: { - storyPatterns: [], + storyPatterns: ['src/components/focus-zone/**/*.stories.?(ts|tsx)'], + }, + win32: { + storyPatterns: ['src/legacy/**/*.stories.?(ts|tsx)'], }, }, }, diff --git a/packages/agentic/native-core/AGENTS.md b/packages/agentic/native-core/AGENTS.md new file mode 100644 index 00000000000..40c1898b4ea --- /dev/null +++ b/packages/agentic/native-core/AGENTS.md @@ -0,0 +1,34 @@ +# Native-core authoring + +- Prefer Swift for new macOS AppKit/domain behavior. Keep ObjC/ObjC++ for + generated protocols, C++ conversion, registration, and renderer integration. +- Paper and Fabric adapters supply resolved views and event sinks to shared + behavior. Shared behavior must not traverse renderer registries. +- Modern and legacy facades share private host implementations. Legacy must + not depend on React-19-only public wrappers. +- Components and genuine modules are peer consumers of services. Do not route + view commands through a global module or create speculative modules. +- Extract only proven reuse. Share fixtures/contracts before forcing AppKit + and Composition into a portable C++ framework. +- AppKit mutations run on the main thread; Windows uses its owning dispatcher. + Module invocation does not establish UI affinity. +- Scope view/anchor/focus identities to attachment, surface/window, and + generation. Legitimate app-wide non-view services have explicit lifecycle; + unscoped global tag/focus registries are forbidden. +- Observers, monitors, requests, and popup links have one owner and idempotent + cleanup. Stale cleanup cannot detach a newer registration. +- Async operations settle once through actual native results. Ref availability + is not readiness; a void focus return is not confirmation. Preserve errors, + distinguish refusal/cancellation/unsupported/timeout, and never autoqueue + pre-mount focus requests. +- New wrappers use React 19 ref props. FocusZone preserves its native root; + Callout's deliberate presentation handle is documented in its contract. +- Keep legacy aliases/registrations and Win32 host ownership. Never send a new + managed protocol to a baked host without capability evidence. +- Root exports require equivalent mandatory behavior on every supported + desktop host. Platform previews, compile checks, and skipped tests are not + root admission. +- Keep generated code generated and verify packed native ownership. Preserve + the module-named macOS Swift interoperability header. +- Update [the implementation handoff](plans/modernization.md) with exact + executed validation and remaining work before switching machines. diff --git a/packages/agentic/native-core/README.md b/packages/agentic/native-core/README.md index a5d192ba918..7133d33c848 100644 --- a/packages/agentic/native-core/README.md +++ b/packages/agentic/native-core/README.md @@ -13,8 +13,10 @@ The existing unstyled `Callout` and `FocusZone` JavaScript wrappers, public types, tests, stories, and specifications live under `src/legacy` and are exported only from `@fluentui-react-native/native-core/legacy`. They are not exported by the root entrypoint, which is reserved for modern cross-platform -APIs. The `macos`, `windows`, `win`, and `win32` entrypoints are reserved for -future host-specific APIs and remain empty. +APIs. The `macos` and `windows` now expose React 19 modern Callout/FocusZone previews, +live native targets, and typed operation outcomes. The root, `win`, and +`win32` remain unadmitted for modern components. Legacy Win32 uses its existing +host registrations without new managed commands. `@fluentui-react-native/callout` and `@fluentui-react-native/focus-zone` are JS-only compatibility shims under `packages/shim`. Deprecated type aliases and @@ -52,8 +54,8 @@ Swift and declare them with `RCT_EXTERN_METHOD`. | -------------------------------------------- | ------------------------------------------- | ---------------------- | | `@fluentui-react-native/native-core` | Cross-platform APIs | `src/index.ts` | | `@fluentui-react-native/native-core/legacy` | Legacy Callout/FocusZone wrappers and types | `src/legacy/index.ts` | -| `@fluentui-react-native/native-core/macos` | macOS-specific APIs | `src/macos/index.ts` | -| `@fluentui-react-native/native-core/windows` | React Native Windows-specific APIs | `src/windows/index.ts` | +| `@fluentui-react-native/native-core/macos` | Modern macOS component previews | `src/macos/index.ts` | +| `@fluentui-react-native/native-core/windows` | Modern Windows component previews | `src/windows/index.ts` | | `@fluentui-react-native/native-core/win` | APIs shared by Windows and Win32 | `src/win/index.ts` | | `@fluentui-react-native/native-core/win32` | Office React Native Win32-specific APIs | `src/win32/index.ts` | @@ -253,3 +255,34 @@ STORYBOOK_SMOKE_STORY='native-*' yarn storybook smoke --macos --mode stories-and This traverses the full catalog before running the selected native tests. The macOS Desktop Driver currently targets Fabric; a successful Paper build does not establish Paper runtime or input parity. + +## Modern component previews + +```tsx +import { Callout, FocusZone, useNativeViewTarget } from '@fluentui-react-native/native-core/macos'; +// Windows uses the equivalent /windows entrypoint. +``` + +These previews require React 19 and the current native-core registration. +`useNativeViewTarget` supplies a passive live callback binding; attach its +`ref` to a materialized native View. Rect/point anchors use local logical +coordinates relative to such a live owner, not global screen coordinates. + +Callout's ref is an opaque-presentation command handle; `content.ref` is the +actual View ref. Controlled `open` is requested presence, and physical native +closure latches the generation until false-to-true or a changed +`presentationKey`. `onReady` is separate from legacy `onShow`. FocusZone +preserves its actual native root `ref` and publishes confirmed requests through +`commandsRef`. Neither ref assignment nor a void native focus return is +confirmation. Requests support abort and timeout, and reject stale lifetimes. + +Modern and legacy facades use the same private host components and native +registrations. macOS shares Swift focus eligibility/actual-responder and +placement behavior between the renderer adapters; the existing navigation +algorithm is not rewritten merely to change language. + +Windows source/codegen is wired but requires native build/runtime qualification. +Root/Win32 admission, older-peer builds, popup-family transactions, +restoration/Tab policy, text fragments, modal containment, and a real module +remain explicit gates. See the repository's `plans/modernization.md` for the +cross-machine progress and exact validation handoff. diff --git a/packages/agentic/native-core/macos/components/Callout/fabric/RCTCalloutComponentView.mm b/packages/agentic/native-core/macos/components/Callout/fabric/RCTCalloutComponentView.mm index 2dee187651e..6004f0bb672 100644 --- a/packages/agentic/native-core/macos/components/Callout/fabric/RCTCalloutComponentView.mm +++ b/packages/agentic/native-core/macos/components/Callout/fabric/RCTCalloutComponentView.mm @@ -14,6 +14,7 @@ #import #import "FRNNativeCore-Swift.h" +#import "../../../shared/FRNNativeCoreComponentLookup.h" using namespace facebook::react; @@ -55,23 +56,6 @@ static void RCTApplyCalloutAppearance( [calloutView setNeedsDisplay:YES]; } -static RCTPlatformView *RCTFindComponentViewWithTag(RCTPlatformView *rootView, NSInteger tag) -{ - if ([rootView conformsToProtocol:@protocol(RCTComponentViewProtocol)] && - [((id)rootView).reactTag integerValue] == tag) { - return rootView; - } - - for (RCTPlatformView *subview in rootView.subviews) { - RCTPlatformView *match = RCTFindComponentViewWithTag(subview, tag); - if (match) { - return match; - } - } - - return nil; -} - @interface RCTCalloutComponentView () @end @@ -105,6 +89,9 @@ - (instancetype)initWithFrame:(CGRect)frame _calloutView.onDismiss = ^(__unused NSDictionary *event) { [weakSelf emitOnDismiss]; }; + _calloutView.onReady = ^(NSDictionary *event) { [weakSelf emitReady:event]; }; + _calloutView.onClosed = ^(NSDictionary *event) { [weakSelf emitClosed:event]; }; + _calloutView.onOperationResult = ^(NSDictionary *event) { [weakSelf emitResult:event]; }; _touchHandler = [RCTSurfaceTouchHandler new]; [_touchHandler attachToView:_calloutView.contentProxyView]; @@ -128,6 +115,10 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & { const auto &newProps = *std::static_pointer_cast(props); + _calloutView.commandGeneration = newProps.commandGeneration; + _calloutView.anchorMode = [NSString stringWithUTF8String:toString(newProps.anchorMode).c_str()]; + _calloutView.placementHint = [NSString stringWithUTF8String:toString(newProps.directionalHint).c_str()]; + _calloutView.gapSpace = newProps.gapSpace; _calloutView.directionalHint = RCTNSRectEdgeFromDirectionalHint(newProps.directionalHint); _calloutView.setInitialFocus = newProps.setInitialFocus; @@ -164,6 +155,7 @@ - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics - (void)prepareForRecycle { + [_calloutView invalidatePresentation]; [super prepareForRecycle]; _targetTag = 0; [_calloutView setAnchorView:nil]; @@ -184,10 +176,40 @@ - (void)blurWindow [_calloutView blurWindow]; } +- (void)requestFocus:(NSInteger)generation requestId:(NSInteger)requestId targetTag:(NSInteger)targetTag +{ + [_calloutView requestFocus:generation requestId:requestId + target:FRNNativeCoreFindView(_calloutView.contentProxyView, targetTag)]; +} +- (void)close:(NSInteger)generation requestId:(NSInteger)requestId +{ + [_calloutView close:generation requestId:requestId]; +} +- (void)reposition:(NSInteger)generation requestId:(NSInteger)requestId +{ + [_calloutView reposition:generation requestId:requestId]; +} +- (void)emitReady:(NSDictionary *)event +{ + if (_eventEmitter) std::static_pointer_cast(_eventEmitter)->onReady( + {.generation = [event[@"generation"] intValue]}); +} +- (void)emitClosed:(NSDictionary *)event +{ + if (_eventEmitter) std::static_pointer_cast(_eventEmitter)->onClosed( + {.generation = [event[@"generation"] intValue], .reason = std::string([event[@"reason"] UTF8String])}); +} +- (void)emitResult:(NSDictionary *)event +{ + if (_eventEmitter) std::static_pointer_cast(_eventEmitter)->onOperationResult( + {.generation = [event[@"generation"] intValue], .requestId = [event[@"requestId"] intValue], + .status = std::string([event[@"status"] UTF8String])}); +} + - (void)updateAnchorView { RCTPlatformView *rootView = self.window.contentView; - [_calloutView setAnchorView:_targetTag > 0 && rootView ? RCTFindComponentViewWithTag(rootView, _targetTag) : nil]; + [_calloutView setAnchorView:_targetTag > 0 && rootView ? FRNNativeCoreFindView(rootView, _targetTag) : nil]; } - (void)emitOnShow diff --git a/packages/agentic/native-core/macos/components/Callout/paper/FRNCalloutManager.m b/packages/agentic/native-core/macos/components/Callout/paper/FRNCalloutManager.m index bb54cde2f91..88f9dd8a728 100644 --- a/packages/agentic/native-core/macos/components/Callout/paper/FRNCalloutManager.m +++ b/packages/agentic/native-core/macos/components/Callout/paper/FRNCalloutManager.m @@ -41,11 +41,52 @@ @interface RCT_EXTERN_MODULE(RCTCalloutManager, RCTViewManager) RCT_EXTERN_METHOD(blurWindow : (nonnull NSNumber *)viewTag) -RCT_EXPORT_VIEW_PROPERTY(target, NSNumber) +RCT_CUSTOM_VIEW_PROPERTY(target, NSNumber, FRNCalloutView) +{ + NSNumber *tag = [RCTConvert NSNumber:json]; + NSView *target = tag ? [self.bridge.uiManager viewForReactTag:tag] : nil; + if (tag && !target) RCTLogError(@"Callout target %@ is not mounted in its bridge.", tag); + [view setAnchorView:target]; +} + +RCT_EXPORT_VIEW_PROPERTY(commandGeneration, NSInteger) +RCT_EXPORT_VIEW_PROPERTY(anchorMode, NSString) +RCT_EXPORT_VIEW_PROPERTY(gapSpace, CGFloat) +RCT_EXPORT_VIEW_PROPERTY(onReady, RCTDirectEventBlock) +RCT_EXPORT_VIEW_PROPERTY(onClosed, RCTDirectEventBlock) +RCT_EXPORT_VIEW_PROPERTY(onOperationResult, RCTDirectEventBlock) + +RCT_EXPORT_METHOD(requestFocus:(nonnull NSNumber *)viewTag generation:(NSInteger)generation + requestId:(NSInteger)requestId targetTag:(NSInteger)targetTag) +{ + dispatch_async(dispatch_get_main_queue(), ^{ + FRNCalloutView *view = (FRNCalloutView *)[self.bridge.uiManager viewForReactTag:viewTag]; + if (![view isKindOfClass:[FRNCalloutView class]]) return; + [view requestFocus:generation requestId:requestId target:[self.bridge.uiManager viewForReactTag:@(targetTag)]]; + }); +} +RCT_EXPORT_METHOD(close:(nonnull NSNumber *)viewTag generation:(NSInteger)generation requestId:(NSInteger)requestId) +{ + dispatch_async(dispatch_get_main_queue(), ^{ + FRNCalloutView *view = (FRNCalloutView *)[self.bridge.uiManager viewForReactTag:viewTag]; + if ([view isKindOfClass:[FRNCalloutView class]]) [view close:generation requestId:requestId]; + }); +} +RCT_EXPORT_METHOD(reposition:(nonnull NSNumber *)viewTag generation:(NSInteger)generation requestId:(NSInteger)requestId) +{ + dispatch_async(dispatch_get_main_queue(), ^{ + FRNCalloutView *view = (FRNCalloutView *)[self.bridge.uiManager viewForReactTag:viewTag]; + if ([view isKindOfClass:[FRNCalloutView class]]) [view reposition:generation requestId:requestId]; + }); +} RCT_EXPORT_VIEW_PROPERTY(anchorRect, screenRect) -RCT_EXPORT_VIEW_PROPERTY(directionalHint, NSRectEdge) +RCT_CUSTOM_VIEW_PROPERTY(directionalHint, NSRectEdge, FRNCalloutView) +{ + view.placementHint = [json isKindOfClass:[NSString class]] ? json : @""; + view.directionalHint = json ? [RCTConvert NSRectEdge:json] : defaultView.directionalHint; +} RCT_EXPORT_VIEW_PROPERTY(setInitialFocus, BOOL) diff --git a/packages/agentic/native-core/macos/components/Callout/shared/CalloutPlacement.swift b/packages/agentic/native-core/macos/components/Callout/shared/CalloutPlacement.swift new file mode 100644 index 00000000000..c11f5f8a34a --- /dev/null +++ b/packages/agentic/native-core/macos/components/Callout/shared/CalloutPlacement.swift @@ -0,0 +1,36 @@ +import AppKit + +struct CalloutPlacement { + static func frame(anchor: NSRect, size: NSSize, workArea: NSRect, + edge: NSRectEdge, hint: String, gap: CGFloat, rtl: Bool) -> NSRect { + func origin(_ edge: NSRectEdge) -> NSPoint { + let horizontal = edge == .minY || edge == .maxY + let centered = hint.hasSuffix("Center") + let trailing = horizontal ? hint.hasSuffix("RightEdge") : hint.hasSuffix("BottomEdge") + let x = centered ? anchor.midX - size.width / 2 : + trailing ? anchor.maxX - size.width : + (hint.isEmpty && rtl ? anchor.maxX - size.width : anchor.minX) + let y = centered ? anchor.midY - size.height / 2 : + trailing ? anchor.minY : anchor.maxY - size.height + switch edge { + case .minX: return NSPoint(x: anchor.minX - size.width - gap, y: y) + case .maxX: return NSPoint(x: anchor.maxX + gap, y: y) + case .minY: return NSPoint(x: x, y: anchor.maxY + gap) + case .maxY: return NSPoint(x: x, y: anchor.minY - size.height - gap) + @unknown default: preconditionFailure("Unknown Callout edge") + } + } + var frame = NSRect(origin: origin(edge), size: size) + let opposite: NSRectEdge = edge == .minX ? .maxX : edge == .maxX ? .minX : edge == .minY ? .maxY : .minY + let alternate = NSRect(origin: origin(opposite), size: size) + let area = frame.intersection(workArea) + let alternateArea = alternate.intersection(workArea) + if !workArea.contains(frame) && + alternateArea.width * alternateArea.height > area.width * area.height { + frame = alternate + } + frame.origin.x = max(workArea.minX, min(frame.minX, workArea.maxX - size.width)) + frame.origin.y = max(workArea.minY, min(frame.minY, workArea.maxY - size.height)) + return frame + } +} diff --git a/packages/agentic/native-core/macos/components/Callout/shared/CalloutView.swift b/packages/agentic/native-core/macos/components/Callout/shared/CalloutView.swift index 72d40ab9459..343ca2d9310 100644 --- a/packages/agentic/native-core/macos/components/Callout/shared/CalloutView.swift +++ b/packages/agentic/native-core/macos/components/Callout/shared/CalloutView.swift @@ -2,453 +2,281 @@ import AppKit import Foundation #if USE_REACT_AS_MODULE import React -#endif // USE_REACT_AS_MODULE +#endif @objc(FRNCalloutView) open class CalloutView: RCTView, CalloutWindowLifeCycleDelegate { - - @objc public var target: NSNumber? { - didSet { - let targetView = bridge?.uiManager.view(forReactTag: target) - if (targetView == nil && target != nil) { - preconditionFailure("Invalid target") - } - anchorView = targetView - updateCalloutFrameToAnchor() - } - } - - @objc public var anchorRect: NSRect = .null { - didSet { - updateCalloutFrameToAnchor() - } - } - - @objc public var directionalHint: NSRectEdge = .maxY { - didSet { - updateCalloutFrameToAnchor() - } - } - - @objc public var setInitialFocus: Bool = false - - @objc public var onShow: RCTDirectEventBlock? - - @objc public var onDismiss: RCTDirectEventBlock? - - @objc public func focusWindow() { - calloutWindow.makeKey() - } - - @objc public func blurWindow() { - calloutWindow.parent?.makeKey() - } - - public weak var bridge: RCTBridge? - - @objc public override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - - backgroundColor = .clear - borderColor = .clear - - // Listens for mouse clicks in the main menu bar while callout is shown - NotificationCenter.default.addObserver( - self, - selector: #selector(menuDidBeginTracking), - name: NSMenu.didBeginTrackingNotification, - object: nil) - } - - public required init?(coder: NSCoder) { - preconditionFailure() - } - - convenience init(bridge: RCTBridge) { - self.init(frame: .zero) - self.bridge = bridge - } - - public override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - if (window != nil) { - showCallout() - } else { - dismissCallout() - } - } - - public override func updateLayer() { - if let layer = calloutWindow.contentView?.layer { - layer.borderColor = borderColor.cgColor - layer.borderWidth = borderWidth - layer.backgroundColor = backgroundColor.cgColor - layer.cornerRadius = borderRadius - } - } - - // MARK: Fabric interface - - @objc public var contentProxyView: NSView { - return proxyView - } - - @objc public func setAnchorView(_ view: NSView?) { - anchorView = view - updateCalloutFrameToAnchor() - } - - @objc public func mountContentSubview(_ subview: NSView, at index: Int) { - if index >= proxyView.subviews.count { - proxyView.addSubview(subview) - } else { - proxyView.addSubview(subview, positioned: .below, relativeTo: proxyView.subviews[index]) - } - } - - @objc public func unmountContentSubview(_ subview: NSView) { - if subview.superview == proxyView { - subview.removeFromSuperview() - } - } - - @objc public func updateContentSize(_ size: NSSize) { - proxyView.frame = NSRect(origin: .zero, size: size) - updateCalloutFrameToAnchor() - } - - // MARK: RCTComponent Overrides - - public override func insertReactSubview(_ subview: NSView!, at atIndex: Int) { - proxyView.insertReactSubview(subview, at: atIndex) - } - - public override func didUpdateReactSubviews() { - proxyView.didUpdateReactSubviews() - } - - public override func reactSetFrame(_ frame: CGRect) { - proxyView.reactSetFrame(frame) - updateCalloutFrameToAnchor() - } - - // MARK: WindowLifeCycleDelegate - - func calloutWillDismiss(window: CalloutWindow) { - onDismissCallout() - } - - // MARK: Private methods - - private func showCallout() { - guard !isCalloutWindowShown else { - return - } - - updateCalloutFrameToAnchor() - calloutWindow.orderFront(self) - if (setInitialFocus) { - calloutWindow.makeKey() - } - - // Dismiss the Callout if the window is no longer active. - NotificationCenter.default.addObserver(self, selector: #selector(dismissCallout), name: NSApplication.didResignActiveNotification, object: nil) - - mouseEventMonitor.addLocalMonitorForEvents(matching: .leftMouseDown, handler: { [weak self] (event) -> NSEvent? in - func isClickInsideWindowHierarchy(window: NSWindow?, event: NSEvent) -> Bool { - guard let window = window else { - return false - } - guard let clickedWindow = event.window else { - return false - } - - var isClickInHierarchy = false - - if (window.isEqual(to: clickedWindow)) { - isClickInHierarchy = true - } else { - if let childWindows = window.childWindows { - for childWindow in childWindows { - isClickInHierarchy = isClickInsideWindowHierarchy(window: childWindow, event: event) - } - } - } - - return isClickInHierarchy - } - - var window: NSWindow? = self?.calloutWindow - while (window?.parent as? CalloutWindow != nil) { - window = window?.parent - } - - if (!isClickInsideWindowHierarchy(window: window, event: event)) { - self?.dismissCallout() - } - - return event - }) - - isCalloutWindowShown = true - onShowCallout() - } - - @objc private func dismissCallout() { - guard isCalloutWindowShown else { - return - } - - // Dismiss any children Callouts (I.E: Submenus) first - if let childWindows = calloutWindow.childWindows { - for childWindow in childWindows { - if let childCallout = childWindow as? CalloutWindow { - childCallout.dismissCallout() - } - } - } - - calloutWindow.dismissCallout() - - NotificationCenter.default.removeObserver(self) - mouseEventMonitor.removeMonitor() - - isCalloutWindowShown = false - } - - /// Sets the frame of the Callout Window (in screen coordinates to be off of the Anchor on the preferred edge - private func updateCalloutFrameToAnchor() { - guard window != nil else { - return - } - - // Prefer anchorView over anchorRect if available - let anchorScreenRect = anchorView != nil ? calculateAnchorViewScreenRect() : calculateAnchorRectScreenRect() - let calloutScreenRect = bestCalloutRect(relativeTo: anchorScreenRect) - - // Because we immediately update the rect as props come in, there's a possibility that we have neither - // of anchorRect and target. Don't update until we have at least one. - guard !calloutScreenRect.isEmpty else { - return - } - - proxyView.frame.origin = .zero - calloutWindow.setFrame(calloutScreenRect, display: false) - } - - /// Calculates the NSRect of the Anchor Rect in screen coordinates - private func calculateAnchorRectScreenRect() -> NSRect { - guard let window = window else { - preconditionFailure("No window found") - } - - if (window.screen == nil) { - preconditionFailure("No screen Available") - } - - // The anchor Rect is given in the coordinate space of the root view. - // Find the root view to convert to screen coordinates - var rootView: NSView = self - while (!rootView.isReactRootView()) { - rootView = rootView.reactSuperview() - } - - let anchorRect = self.anchorRect - - // Since the root view is flipped, we already have anchorRect in the "correct" - // (i.e. flipped) coordinate space. Since we need to provide screen rects to Apple, - // we will once again arrive at the "correct" (not flipped) coordinate space. - let anchorRectInWindow = rootView.convert(anchorRect, to: nil) - let anchorRectInScreenCoordinates = window.convertToScreen(anchorRectInWindow) - - return anchorRectInScreenCoordinates - } - - /// Calculates the NSRect of the anchorView in the coordinate space of the current screen - private func calculateAnchorViewScreenRect() -> NSRect { - guard let anchorView = anchorView else { - preconditionFailure("No anchor view provided to position the Callout") - } - - guard let window = window else { - preconditionFailure("No window found") - } - - let anchorBoundsInWindow = anchorView.convert(anchorView.bounds, to: nil) - let anchorFrameInScreenCoordinates = window.convertToScreen(anchorBoundsInWindow) - - return anchorFrameInScreenCoordinates - } - - /// Calculates the rect in screen coordinates the callout should be positioned in relative to the anchor rect, adjusting if we are close to a screen edge - private func bestCalloutRect(relativeTo anchorScreenRect: NSRect) -> NSRect { - - guard let screenFrame = window?.screen?.visibleFrame ?? NSScreen.main?.visibleFrame else { - preconditionFailure("No Screen Available") - } - - let calloutFrame = proxyView.frame - - // Find our preferred origin based on the directional hint - let calloutOrigin: NSPoint = { - var origin = NSPoint() - - switch(directionalHint) { - case .minX: - origin.x = NSMinX(anchorScreenRect) - calloutFrame.size.width - origin.y = NSMaxY(anchorScreenRect) - calloutFrame.size.height - case .minY: - origin.x = NSMinX(anchorScreenRect) - origin.y = NSMaxY(anchorScreenRect) - case .maxX: - origin.x = NSMaxX(anchorScreenRect) - origin.y = NSMaxY(anchorScreenRect) - calloutFrame.size.height - case .maxY: - // When in RTL mode, align the right edges of the menu and flyout anchor - if (NSApp.userInterfaceLayoutDirection == .rightToLeft || RCTI18nUtil.sharedInstance().isRTL()) { - origin.x = NSMaxX(anchorScreenRect) - calloutFrame.size.width - } else { - origin.x = NSMinX(anchorScreenRect); - } - origin.y = NSMinY(anchorScreenRect) - calloutFrame.size.height - @unknown default: - preconditionFailure("Unknown directional hint") - } - return origin - }() - - var calloutScreenRect = NSRect(origin: calloutOrigin, size: calloutFrame.size) - - // Reposition the callout if it doesn't fit on screen - if (!NSContainsRect(screenFrame, calloutScreenRect)) { - switch(directionalHint) { - case .minX: - // If we go off the left edge, we may need to flip to presenting rightward from the rightEdge of anchorScreenRect - if (NSMinX(calloutScreenRect) < NSMinX(screenFrame)) { - let maxXEdgeSpace = NSMaxX(screenFrame) - NSMaxX(anchorScreenRect) - let minXEdgeSpace = NSMinX(anchorScreenRect) - NSMinX(screenFrame) - if (maxXEdgeSpace > minXEdgeSpace) { - calloutScreenRect.origin.x = NSMaxX(anchorScreenRect); - } - } - // If we go off the bottom of the screen, just slide up until we fit - if (NSMinY(calloutScreenRect) < NSMinY(screenFrame)) { - calloutScreenRect.origin.y = NSMinY(screenFrame) - } - case .minY: - // If we go off the top of the screen, check if there's more room on screen below the anchorScreenRect. - if (NSMaxY(calloutScreenRect) > NSMaxY(screenFrame)) { - let maxYEdgeSpace = NSMaxY(screenFrame) - NSMaxY(anchorScreenRect) - let minYEdgeSpace = NSMinY(anchorScreenRect) - NSMinY(screenFrame) - - // Flip to presenting below anchorScreenRect - if (minYEdgeSpace > maxYEdgeSpace) { - calloutScreenRect.origin.y = NSMinY(anchorScreenRect) - NSHeight(calloutScreenRect) - } - } - // If we go off the right edge, just slide to the left until we fit - if (NSMaxX(calloutScreenRect) > NSMaxX(screenFrame)) { - calloutScreenRect.origin.x = NSMaxY(screenFrame) - NSWidth(calloutScreenRect) - } - case .maxX: - // If we go off the right edge, we may need to flip to presenting leftward from the leftEdge of anchorScreenRect - if (NSMaxX(calloutScreenRect) > NSMaxX(screenFrame)) { - let maxXEdgeSpace = NSMaxX(screenFrame) - NSMaxX(anchorScreenRect) - let minXEdgeSpace = NSMinX(anchorScreenRect) - NSMinX(screenFrame) - if (minXEdgeSpace > maxXEdgeSpace) { - calloutScreenRect.origin.x = NSMinX(anchorScreenRect) - NSWidth(calloutScreenRect) - } - } - // If we go off the bottom of the screen, just slide up until we fit - if (NSMinY(calloutScreenRect) < NSMinY(screenFrame)) { - calloutScreenRect.origin.y = NSMinY(screenFrame); - } - case .maxY: - // If we go off the bottom of the screen, check if there's more room on screen above the anchorScreenRect. - if (NSMinY(calloutScreenRect) < NSMinY(screenFrame)) { - let maxYEdgeSpace = NSMaxY(screenFrame) - NSMaxY(anchorScreenRect) - let minYEdgeSpace = NSMinY(anchorScreenRect) - NSMinY(screenFrame) - - // Flip to presenting above anchorScreenRect - if (maxYEdgeSpace > minYEdgeSpace) { - calloutScreenRect.origin.y = NSMaxY(anchorScreenRect) - } - } - // If we go off the right edge, just slide to the left until we fit - if (NSMaxX(calloutScreenRect) > NSMaxX(screenFrame)) { - calloutScreenRect.origin.x = NSMaxX(screenFrame) - NSWidth(calloutScreenRect) - } - // If we go off the left edge in RTL, just slide to the right so we're fully onscreen - if (NSMinX(calloutScreenRect) < NSMinX(screenFrame)) { - calloutScreenRect.origin.x = 0; - } - @unknown default: - preconditionFailure("Unknown directional hint") - } - } - return calloutScreenRect - } - - private func onShowCallout() { - onShow?([:]) - } - - private func onDismissCallout() { - if let onDismiss = onDismiss { - let event: [AnyHashable: Any] = ["target": reactTag ?? NSNumber(value: 0)] - onDismiss(event) - } - } - - // The app's main menu bar is active while callout is shown, dismiss. - @objc private func menuDidBeginTracking() { - self.dismissCallout() - } - - // MARK: Private variables - - /// The view the Callout is presented from. - private var anchorView: NSView? - - /// The view we forward Callout's Children to. It's hosted within the CalloutWindow's - /// view hierarchy, ensuring our React Views are not placed in the main window. - private lazy var proxyView: NSView = { - let visualEffectView = FlippedVisualEffectView() - visualEffectView.translatesAutoresizingMaskIntoConstraints = false - visualEffectView.material = .menu - visualEffectView.state = .active - visualEffectView.wantsLayer = true - - /** - * We can't directly call touchHandler.attach(to:) because `visualEffectView` is not an RCTUIView. - * We get around this limitation by just replicating what `attach` did internally: add a gestureRecognizer. - */ - if let bridge = bridge { - guard let touchHandler = RCTTouchHandler(bridge: bridge) else { - preconditionFailure("Callout could not create RCTTouchHandler") - } - visualEffectView.addGestureRecognizer(touchHandler) - } - - return visualEffectView - }() - - private lazy var calloutWindow: CalloutWindow = { - let window = CalloutWindow() - window.lifeCycleDelegate = self - - guard let contentView = window.contentView else { - preconditionFailure("Callout window has no content view") - } - - contentView.addSubview(proxyView) - if let parentWindow = self.window { - parentWindow.addChildWindow(window, ordered: .above) - } - return window - }() - - private var mouseEventMonitor = GuardedEventMonitor() - - private var isCalloutWindowShown = false + @objc public var commandGeneration: Int = 0 { + didSet { + if oldValue != commandGeneration { readyGeneration = 0; closedGeneration = nil } + } + } + @objc public var anchorMode = "legacy" { didSet { updatePresentation() } } + @objc public var placementHint = "" { didSet { updatePresentation() } } + @objc public var gapSpace: CGFloat = 0 { didSet { updatePresentation() } } + @objc public var anchorRect: NSRect = .null { didSet { updatePresentation() } } + @objc public var directionalHint: NSRectEdge = .maxY { didSet { updatePresentation() } } + @objc public var setInitialFocus = false + @objc public var onShow: RCTDirectEventBlock? + @objc public var onDismiss: RCTDirectEventBlock? + @objc public var onReady: RCTDirectEventBlock? + @objc public var onClosed: RCTDirectEventBlock? + @objc public var onOperationResult: RCTDirectEventBlock? + public weak var bridge: RCTBridge? + + private weak var anchorView: NSView? + private var popupWindow: CalloutWindow? + private var observers: [NSObjectProtocol] = [] + private var mouseEventMonitor = GuardedEventMonitor() + private var shown = false + private var readyGeneration = 0 + private var closedGeneration: Int? + private var dismissReason = "native-light-dismiss" + private var closeRequest: (Int, Int)? + + @objc public override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + backgroundColor = .clear + borderColor = .clear + } + + public required init?(coder: NSCoder) { preconditionFailure("Callout does not support decoding") } + + convenience init(bridge: RCTBridge) { + self.init(frame: .zero) + self.bridge = bridge + } + + deinit { removeObservers() } + + public override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window == nil { dismissReason = "host-detached"; dismissCallout() } + else { + if commandGeneration == 0 { closedGeneration = nil } + updatePresentation() + } + } + + public override func updateLayer() { + guard let layer = popupWindow?.contentView?.layer else { return } + layer.borderColor = borderColor.cgColor + layer.borderWidth = borderWidth + layer.backgroundColor = backgroundColor.cgColor + layer.cornerRadius = borderRadius + } + + @objc public var contentProxyView: NSView { proxyView } + @objc public func focusWindow() { popupWindow?.makeKey() } + @objc public func blurWindow() { popupWindow?.parent?.makeKey() } + + @objc public func setAnchorView(_ view: NSView?) { + let lost = anchorView != nil && view == nil && commandGeneration > 0 + anchorView = view + if lost { dismissReason = "anchor-lost"; dismissCallout() } + if shown { installObservers() } + updatePresentation() + } + + @objc public func mountContentSubview(_ subview: NSView, at index: Int) { + if index >= proxyView.subviews.count { proxyView.addSubview(subview) } + else { proxyView.addSubview(subview, positioned: .below, relativeTo: proxyView.subviews[index]) } + updatePresentation() + } + + @objc public func unmountContentSubview(_ subview: NSView) { + if subview.superview === proxyView { subview.removeFromSuperview() } + } + + @objc public func updateContentSize(_ size: NSSize) { + proxyView.frame = NSRect(origin: .zero, size: size) + updatePresentation() + } + + public override func insertReactSubview(_ subview: NSView!, at atIndex: Int) { + proxyView.insertReactSubview(subview, at: atIndex) + } + public override func didUpdateReactSubviews() { + proxyView.didUpdateReactSubviews() + updatePresentation() + } + public override func reactSetFrame(_ frame: CGRect) { + proxyView.reactSetFrame(frame) + updatePresentation() + } + + @objc public func requestFocus(_ generation: Int, requestId: Int, target: NSView?) { + emitResult(generation, requestId, operationStatus(generation) ?? + NativeFocusService.focus(within: proxyView, target: target, strategy: "target")) + } + @objc public func close(_ generation: Int, requestId: Int) { + if let blocked = operationStatus(generation) { emitResult(generation, requestId, blocked); return } + closeRequest = (generation, requestId) + dismissReason = "programmatic" + dismissCallout() + } + @objc public func reposition(_ generation: Int, requestId: Int) { + if let blocked = operationStatus(generation) { emitResult(generation, requestId, blocked); return } + emitResult(generation, requestId, updateFrame() ? "confirmed" : "not-ready") + } + @objc public func invalidatePresentation() { + dismissReason = "host-detached" + dismissCallout() + } + + private func operationStatus(_ generation: Int) -> String? { + if generation <= 0 || generation != commandGeneration { return "stale" } + if !shown || readyGeneration != generation { return "not-ready" } + return nil + } + private func emitResult(_ generation: Int, _ requestId: Int, _ status: String) { + onOperationResult?(["generation": generation, "requestId": requestId, "status": status]) + } + + func calloutWillDismiss(window: CalloutWindow) { + for child in window.childWindows ?? [] { + if let popup = child as? CalloutWindow { popup.dismissCallout() } + } + } + func calloutDidDismiss(window: CalloutWindow) { + guard shown else { return } + shown = false + closedGeneration = commandGeneration + mouseEventMonitor.removeMonitor() + removeObservers() + if let request = closeRequest { + closeRequest = nil + emitResult(request.0, request.1, "confirmed") + } + if commandGeneration > 0 { + onClosed?(["generation": commandGeneration, "reason": dismissReason]) + } + onDismiss?(["target": reactTag ?? NSNumber(value: 0)]) + } + + private func updatePresentation() { + guard window != nil else { return } + let positioned = updateFrame() + if !shown { + if closedGeneration == commandGeneration { return } + if commandGeneration > 0 && + (!positioned || proxyView.subviews.isEmpty) { return } + let popup = calloutWindow + if let owner = anchorView?.window ?? window, popup.parent !== owner { + popup.parent?.removeChildWindow(popup) + owner.addChildWindow(popup, ordered: .above) + } + shown = true + dismissReason = "native-light-dismiss" + popup.orderFront(self) + if setInitialFocus { popup.makeKey() } + installObservers() + mouseEventMonitor.addLocalMonitorForEvents(matching: .leftMouseDown) { [weak self] event in + guard let self = self else { return event } + func contains(_ window: NSWindow, _ clicked: NSWindow) -> Bool { + window === clicked || (window.childWindows ?? []).contains { contains($0, clicked) } + } + var root: NSWindow = popup + while let parent = root.parent as? CalloutWindow { root = parent } + if let clicked = event.window, !contains(root, clicked) { self.dismissCallout() } + return event + } + onShow?([:]) + } + if commandGeneration > 0 && positioned && !proxyView.subviews.isEmpty && + readyGeneration != commandGeneration { + readyGeneration = commandGeneration + onReady?(["generation": commandGeneration]) + } + } + + private func dismissCallout() { + guard shown else { return } + popupWindow?.dismissCallout() + } + + private func anchorInScreen() -> NSRect? { + if let anchor = anchorView, let owner = anchor.window { + var local = anchor.bounds + if anchorMode == "rect" || anchorMode == "point" { + local = anchorRect + if !anchor.isFlipped { local.origin.y = anchor.bounds.height - local.maxY } + } + return owner.convertToScreen(anchor.convert(local, to: nil)) + } + if commandGeneration > 0 || anchorRect.isNull || anchorRect.isInfinite { return nil } + guard let owner = window else { return nil } + var root: NSView = self + while !root.isReactRootView() { + guard let parent = root.reactSuperview() else { return nil } + root = parent + } + return owner.convertToScreen(root.convert(anchorRect, to: nil)) + } + + private func updateFrame() -> Bool { + guard window != nil, let anchor = anchorInScreen(), + proxyView.frame.width > 0, proxyView.frame.height > 0, + let area = (anchorView?.window ?? window)?.screen?.visibleFrame else { return false } + let frame = CalloutPlacement.frame(anchor: anchor, size: proxyView.frame.size, + workArea: area, edge: directionalHint, hint: placementHint, gap: gapSpace, + rtl: NSApp.userInterfaceLayoutDirection == .rightToLeft || RCTI18nUtil.sharedInstance().isRTL()) + proxyView.frame.origin = .zero + calloutWindow.setFrame(frame, display: false) + return true + } + + private func observe(_ name: Notification.Name, object: Any?, action: @escaping (CalloutView) -> Void) { + observers.append(NotificationCenter.default.addObserver(forName: name, object: object, queue: .main) { + [weak self] _ in if let self = self { action(self) } + }) + } + private func removeObservers() { + observers.forEach(NotificationCenter.default.removeObserver) + observers.removeAll() + } + private func installObservers() { + removeObservers() + observe(NSApplication.didResignActiveNotification, object: nil) { $0.dismissCallout() } + observe(NSMenu.didBeginTrackingNotification, object: nil) { $0.dismissCallout() } + var current = anchorView + while let view = current { + view.postsFrameChangedNotifications = true + view.postsBoundsChangedNotifications = true + observe(NSView.frameDidChangeNotification, object: view) { $0.updatePresentation() } + observe(NSView.boundsDidChangeNotification, object: view) { $0.updatePresentation() } + current = view.superview + } + if let owner = anchorView?.window ?? window { + for name in [NSWindow.didMoveNotification, NSWindow.didResizeNotification, + NSWindow.didChangeBackingPropertiesNotification, NSWindow.didChangeScreenNotification] { + observe(name, object: owner) { $0.updatePresentation() } + } + } + } + + private lazy var proxyView: NSView = { + let view = FlippedVisualEffectView() + view.translatesAutoresizingMaskIntoConstraints = false + view.material = .menu + view.state = .active + view.wantsLayer = true + if let bridge = bridge { + guard let handler = RCTTouchHandler(bridge: bridge) else { + preconditionFailure("Callout could not create its Paper touch handler") + } + view.addGestureRecognizer(handler) + } + return view + }() + private var calloutWindow: CalloutWindow { + if let popup = popupWindow { return popup } + let popup = CalloutWindow() + popup.lifeCycleDelegate = self + guard let content = popup.contentView else { preconditionFailure("Callout window has no content view") } + content.addSubview(proxyView) + (anchorView?.window ?? window)?.addChildWindow(popup, ordered: .above) + popupWindow = popup + return popup + } } diff --git a/packages/agentic/native-core/macos/components/Callout/shared/CalloutWindow.swift b/packages/agentic/native-core/macos/components/Callout/shared/CalloutWindow.swift index 3995e51d8ab..c66d8c29af4 100644 --- a/packages/agentic/native-core/macos/components/Callout/shared/CalloutWindow.swift +++ b/packages/agentic/native-core/macos/components/Callout/shared/CalloutWindow.swift @@ -4,6 +4,7 @@ import AppKit protocol CalloutWindowLifeCycleDelegate: AnyObject { /// Notify the delegate that the Callout is about to dismiss func calloutWillDismiss(window: CalloutWindow) + func calloutDidDismiss(window: CalloutWindow) } class CalloutWindow: NSWindow { @@ -35,6 +36,6 @@ class CalloutWindow: NSWindow { @objc public func dismissCallout() { lifeCycleDelegate?.calloutWillDismiss(window: self) orderOut(self) + lifeCycleDelegate?.calloutDidDismiss(window: self) } } - diff --git a/packages/agentic/native-core/macos/components/FocusZone/fabric/RCTFocusZoneComponentView.mm b/packages/agentic/native-core/macos/components/FocusZone/fabric/RCTFocusZoneComponentView.mm index 8326d4d086c..8e26f6d419e 100644 --- a/packages/agentic/native-core/macos/components/FocusZone/fabric/RCTFocusZoneComponentView.mm +++ b/packages/agentic/native-core/macos/components/FocusZone/fabric/RCTFocusZoneComponentView.mm @@ -10,6 +10,7 @@ #import #import "../shared/RCTFocusZone.h" +#import "../../../shared/FRNNativeCoreComponentLookup.h" using namespace facebook::react; @@ -53,23 +54,6 @@ static FocusZoneDirection RCTFocusZoneDirectionFromProp(FocusZoneFocusZoneDirect } } -static RCTPlatformView *RCTFindComponentViewWithTag(RCTPlatformView *rootView, NSInteger tag) -{ - if ([rootView conformsToProtocol:@protocol(RCTComponentViewProtocol)] && - [((id)rootView).reactTag integerValue] == tag) { - return rootView; - } - - for (RCTPlatformView *subview in rootView.subviews) { - RCTPlatformView *match = RCTFindComponentViewWithTag(subview, tag); - if (match) { - return match; - } - } - - return nil; -} - static RCTPlatformView *RCTFindComponentViewWithNativeID(RCTPlatformView *rootView, NSString *nativeID) { if ([rootView isKindOfClass:[RCTViewComponentView class]] && @@ -136,6 +120,7 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & const auto &newProps = *std::static_pointer_cast(props); _focusZone.disabled = newProps.disabled; + _focusZone.commandGeneration = newProps.commandGeneration; _focusZone.focusZoneDirection = RCTFocusZoneDirectionFromProp(newProps.focusZoneDirection); _focusZone.navigateAtEnd = RCTNavigateAtEndFromProp(newProps.navigateAtEnd); _focusZone.navigationOrderInRenderOrder = newProps.navigationOrderInRenderOrder; @@ -166,6 +151,22 @@ - (void)prepareForRecycle _defaultResponderNativeID = nil; _defaultResponderTag = 0; _focusZone.defaultResponder = nil; + _focusZone.commandGeneration = 0; +} + +- (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args +{ + RCTFocusZoneHandleCommand(self, commandName, args); +} + +- (void)requestFocus:(NSInteger)generation requestId:(NSInteger)requestId targetTag:(NSInteger)targetTag strategy:(NSString *)strategy +{ + NSView *target = FRNNativeCoreFindView(_focusZone, targetTag); + NSString *status = [_focusZone requestFocusTarget:target strategy:strategy generation:generation]; + if (_eventEmitter) { + std::static_pointer_cast(_eventEmitter)->onOperationResult( + {.generation = (int)generation, .requestId = (int)requestId, .status = std::string(status.UTF8String)}); + } } - (BOOL)acceptsFirstResponder @@ -188,7 +189,7 @@ - (void)updateDefaultResponder { RCTPlatformView *rootView = self.window.contentView; if (_defaultResponderTag > 0 && rootView) { - _focusZone.defaultResponder = RCTFindComponentViewWithTag(rootView, _defaultResponderTag); + _focusZone.defaultResponder = FRNNativeCoreFindView(rootView, _defaultResponderTag); } else if (_defaultResponderNativeID && rootView) { _focusZone.defaultResponder = RCTFindComponentViewWithNativeID(rootView, _defaultResponderNativeID); } else { diff --git a/packages/agentic/native-core/macos/components/FocusZone/paper/RCTFocusZoneManager.m b/packages/agentic/native-core/macos/components/FocusZone/paper/RCTFocusZoneManager.m index 7d4eddaa480..e9de513b8c4 100644 --- a/packages/agentic/native-core/macos/components/FocusZone/paper/RCTFocusZoneManager.m +++ b/packages/agentic/native-core/macos/components/FocusZone/paper/RCTFocusZoneManager.m @@ -8,6 +8,23 @@ @implementation RCTFocusZoneManager RCT_EXPORT_MODULE() RCT_EXPORT_VIEW_PROPERTY(disabled, BOOL) +RCT_EXPORT_VIEW_PROPERTY(commandGeneration, NSInteger) +RCT_EXPORT_VIEW_PROPERTY(onOperationResult, RCTDirectEventBlock) + +RCT_EXPORT_METHOD(requestFocus:(nonnull NSNumber *)viewTag + generation:(NSInteger)generation + requestId:(NSInteger)requestId + targetTag:(NSInteger)targetTag + strategy:(NSString *)strategy) +{ + dispatch_async(dispatch_get_main_queue(), ^{ + RCTFocusZone *view = (RCTFocusZone *)[self.bridge.uiManager viewForReactTag:viewTag]; + if (![view isKindOfClass:[RCTFocusZone class]]) return; + NSView *target = targetTag > 0 ? [self.bridge.uiManager viewForReactTag:@(targetTag)] : nil; + NSString *status = [view requestFocusTarget:target strategy:strategy generation:generation]; + if (view.onOperationResult) view.onOperationResult(@{@"generation": @(generation), @"requestId": @(requestId), @"status": status}); + }); +} RCT_CUSTOM_VIEW_PROPERTY(navigationOrderInRenderOrder, BOOL, RCTFocusZone) { diff --git a/packages/agentic/native-core/macos/components/FocusZone/shared/RCTFocusZone.h b/packages/agentic/native-core/macos/components/FocusZone/shared/RCTFocusZone.h index 1c02e029e5c..afe3ab45a0f 100644 --- a/packages/agentic/native-core/macos/components/FocusZone/shared/RCTFocusZone.h +++ b/packages/agentic/native-core/macos/components/FocusZone/shared/RCTFocusZone.h @@ -18,5 +18,9 @@ typedef NS_ENUM(NSInteger, FocusZoneDirection) { @property(nonatomic) NSString *navigateAtEnd; @property(nonatomic) NSString *tabKeyNavigation; @property(nonatomic) NSView *defaultResponder; +@property(nonatomic) NSInteger commandGeneration; +@property(nonatomic, copy) RCTDirectEventBlock onOperationResult; + +- (NSString *)requestFocusTarget:(NSView *)target strategy:(NSString *)strategy generation:(NSInteger)generation; @end diff --git a/packages/agentic/native-core/macos/components/FocusZone/shared/RCTFocusZone.m b/packages/agentic/native-core/macos/components/FocusZone/shared/RCTFocusZone.m index 28100c9f17b..45ba4decbaf 100644 --- a/packages/agentic/native-core/macos/components/FocusZone/shared/RCTFocusZone.m +++ b/packages/agentic/native-core/macos/components/FocusZone/shared/RCTFocusZone.m @@ -2,6 +2,7 @@ #import #import "RCTFocusZone.h" #import +#import "FRNNativeCore-Swift.h" typedef enum { FocusZoneActionNone, @@ -21,6 +22,14 @@ @implementation RCTFocusZone +- (NSString *)requestFocusTarget:(NSView *)target strategy:(NSString *)strategy generation:(NSInteger)generation +{ + if (generation <= 0 || generation != self.commandGeneration) return @"stale"; + if (self.disabled) return @"not-focusable"; + NSView *destination = [strategy isEqualToString:@"default"] ? self.defaultResponder : target; + return [FRNNativeCoreFocusService focusWithin:self target:destination strategy:strategy]; +} + static NSView *GetFocusableView(NSView *view) { if (![view acceptsFirstResponder]) { diff --git a/packages/agentic/native-core/macos/shared/FRNNativeCoreComponentLookup.h b/packages/agentic/native-core/macos/shared/FRNNativeCoreComponentLookup.h new file mode 100644 index 00000000000..c5db485f31a --- /dev/null +++ b/packages/agentic/native-core/macos/shared/FRNNativeCoreComponentLookup.h @@ -0,0 +1,7 @@ +#pragma once + +#ifdef RCT_NEW_ARCH_ENABLED +#import + +RCTPlatformView *FRNNativeCoreFindView(RCTPlatformView *root, NSInteger tag); +#endif diff --git a/packages/agentic/native-core/macos/shared/FRNNativeCoreComponentLookup.mm b/packages/agentic/native-core/macos/shared/FRNNativeCoreComponentLookup.mm new file mode 100644 index 00000000000..13df441acf8 --- /dev/null +++ b/packages/agentic/native-core/macos/shared/FRNNativeCoreComponentLookup.mm @@ -0,0 +1,15 @@ +#ifdef RCT_NEW_ARCH_ENABLED +#import "FRNNativeCoreComponentLookup.h" + +RCTPlatformView *FRNNativeCoreFindView(RCTPlatformView *root, NSInteger tag) +{ + if (tag <= 0) return nil; + if ([root conformsToProtocol:@protocol(RCTComponentViewProtocol)] && + [((id)root).reactTag integerValue] == tag) return root; + for (RCTPlatformView *child in root.subviews) { + RCTPlatformView *found = FRNNativeCoreFindView(child, tag); + if (found) return found; + } + return nil; +} +#endif diff --git a/packages/agentic/native-core/macos/shared/FRNNativeCoreFocusService.swift b/packages/agentic/native-core/macos/shared/FRNNativeCoreFocusService.swift new file mode 100644 index 00000000000..33a38305df1 --- /dev/null +++ b/packages/agentic/native-core/macos/shared/FRNNativeCoreFocusService.swift @@ -0,0 +1,69 @@ +import AppKit + +@objc(FRNNativeCoreFocusService) +public final class NativeFocusService: NSObject { + @objc(focusWithin:target:strategy:) + public static func focus(within root: NSView, target: NSView?, strategy: String) -> String { + precondition(Thread.isMainThread, "Native focus must execute on the main thread") + guard let window = root.window, window.isVisible, !window.isMiniaturized else { + return "not-ready" + } + guard NSApp.isActive else { return "inactive-window" } + var owner: NSWindow? = window + var ownsKeyWindow = false + while let current = owner { + if current.isKeyWindow { ownsKeyWindow = true; break } + owner = current.parent + } + guard ownsKeyWindow else { return "inactive-window" } + + let destination: NSView? + switch strategy { + case "target": + destination = target + case "default": + destination = target ?? candidates(in: root).first + case "first": + destination = candidates(in: root).first + case "last": + destination = candidates(in: root).last + default: + return "unsupported" + } + guard let destination = destination, destination.window === window, + destination.isDescendant(of: root) else { return "not-mounted" } + guard eligible(destination) else { return "not-focusable" } + if !window.isKeyWindow { window.makeKey() } + guard NSApp.isActive, window.isKeyWindow, + window.makeFirstResponder(destination) else { return "refused" } + if window.firstResponder === destination { return "confirmed" } + if let control = destination as? NSControl, + let editor = control.currentEditor(), window.firstResponder === editor { return "confirmed" } + if let editor = window.firstResponder as? NSTextView, editor.isFieldEditor, + let delegate = editor.delegate as? NSView, delegate.isDescendant(of: destination) { + return "confirmed" + } + return "refused" + } + + private static func eligible(_ view: NSView) -> Bool { + guard view.acceptsFirstResponder, !view.isHiddenOrHasHiddenAncestor, + !view.visibleRect.isEmpty else { return false } + var current: NSView? = view + while let node = current { + if node.alphaValue <= 0 || (node.layer?.opacity ?? 1) <= 0 { return false } + if let control = node as? NSControl, !control.isEnabled { return false } + current = node.superview + } + return true + } + + private static func candidates(in root: NSView) -> [NSView] { + var result: [NSView] = [] + for child in root.subviews { + if eligible(child) { result.append(child) } + else { result.append(contentsOf: candidates(in: child)) } + } + return result + } +} diff --git a/packages/agentic/native-core/plans/modernization.md b/packages/agentic/native-core/plans/modernization.md new file mode 100644 index 00000000000..b0dedd2c435 --- /dev/null +++ b/packages/agentic/native-core/plans/modernization.md @@ -0,0 +1,210 @@ +# Native-core modernization and validation handoff + +## Scope and decisions + +Implementation branch: `user/jasonvmo/native-lib2`. Baseline: +`c42f54fb15fcbfcf15fc926ec7253c6000f6d147`. Keep this work separate from the +ownership consolidation in PR #4354. + +The owner authorized implementation of the cross-reviewed plan on 2026-10-02. +Use Swift for new macOS behavior, thin renderer/generated boundaries, one host +implementation behind modern and legacy facades, and `commandsRef` for +FocusZone's asynchronous command surface. Preserve its actual native root ref. +Callout exposes a presentation handle, not its hidden native marker. + +New APIs initially belong to `/macos` and `/windows`; the root and `/win` are +not admission shortcuts. Win32 keeps its host-provided legacy implementation. +Do not send new protocol fields or commands to a baked Win32 registration. +Do not raise legacy React/RN peer floors implicitly. + +## Implementation plan + +1. Record native-sharing instructions and concrete component contracts. +2. Implement passive live target attachments and shared cancellable request + correlation with generation fencing, native-result confirmation, and no + implicit pre-mount queuing. +3. Extract a private native host boundary so legacy and modern facades consume + the same registration/behavior, without requiring React-19 wrappers from + legacy code. +4. Implement modern FocusZone native refs, commandsRef, target/default/edge + focus requests, and renderer result events. +5. Implement modern Callout controlled presence, actual content refs, + readiness/dismissal separation, view/local-rect/local-point anchors, logical + placement, and presentation-scoped focus/close/reposition operations. +6. Share macOS eligibility and actual-responder normalization in Swift; retain + the proven navigation algorithm until a separately validated Swift port. +7. Qualify flat nonmodal Menu/Popover prerequisites before adapting candidate + family transactions. Keep one navigation owner per key and one native + restoration executor. +8. Separately implement and qualify families, text layout providers, modal + containment, and a genuine module when a real consumer justifies it. +9. Admit root APIs only after all mandatory semantics and declared host/version + combinations are verified. + +## Progress + +- [x] Pinned baseline, candidate, and historical evidence; recorded approved + implementation direction. +- [x] Native-sharing instructions and initial modern contracts. +- [x] Passive live attachments, callback cleanup, generation fencing and + bounded abort/timeout/native-result correlation. +- [x] Private CalloutHost/FocusZoneHost boundary shared by legacy and modern + facades; existing compatibility tests and both shim tests pass. +- [x] Modern FocusZone native ref, commandsRef, enum/default-target translation, + scoped default/first/last/specific native requests, and result events. +- [x] Modern Callout presentation handles, physical-close latch/rearm, + view/local-rect/local-point wire translation, native readiness, + descendant focus, explicit close, and reposition. +- [x] Shared Swift eligibility/field-editor normalization and placement, + Paper/Fabric boundary wiring, owned observer teardown and recycled-popup + reparenting. Initial Fabric native qualification passes. +- [ ] Windows generation/native build/runtime qualification. +- [ ] Win32 legacy regression qualification; no new managed transport. +- [ ] Publishing, peer-floor, root-admission decisions. + +This checklist is updated only with completed work and executed evidence. +Compilation, authored tests, and skipped tests are not native qualification. + +### Executed evidence on macOS + +- Native-core TypeScript build, story type-check, lint and format checks pass. +- `yarn workspace @fluentui-react-native/native-core test`: 17 Node contracts, + 28 Jest/type tests and one snapshot pass. +- Both shim tests pass (two tests each); root `yarn build` passes. +- Uncached repository `yarn lage test --no-cache --concurrency 4` passes all + 82 tasks. A downstream ContextualMenu snapshot caught style-array + normalization through slot ref composition; the shared host now preserves + the legacy style representation without changing that consumer's snapshot. +- All three final desktop JavaScript bundles pass; Storybook format/lint, + package publishing and changeset checks pass. +- `codegen:windows --check` passes; new generated command interfaces match + the committed schema. This is not Windows C++ compilation. +- Storybook macOS prep, native build and JavaScript bundle pass. +- Owned Fabric smoke renders 174 stories and passes three new native cases + with zero skips: Callout readiness/focus/reposition/close/rearm, FocusZone + actual default/edge/specific focus, and disabled-zone refusal. +- Final combined `STORYBOOK_SMOKE_STORY='native-*'` owned Fabric smoke renders + all 174 stories and passes all nine native cases, zero failures/skips: + the three new cases plus six legacy Callout/FocusZone regressions. +- Command stories require explicit owner activation. They do not bypass + inactive-window guards or activate a popup merely to inspect it. +- Independent lifecycle review found render-time slot props bypassing hooks + and RNW entry redirection overriding explicit requests. The facades now + merge into stable hook-owning implementation components (with a committed + slot regression); explicit RNW commands suppress entry redirection while + retaining actual focused-identity confirmation. +- Native regression also caught a legacy presentation re-opening on a late + layout update after dismissal. Closed generations now remain latched for + legacy views until actual reattachment as well as for modern views until + deliberate rearm. Recycled popup windows are reparented before showing. + +### How to exercise the new API + +```tsx +import { FocusZone, Callout, useNativeViewTarget } from '@fluentui-react-native/native-core/macos'; +// Use /windows on RNW. /legacy remains the supported Win32 surface. +``` + +Use `useNativeViewTarget().ref` on a materialized View/Pressable. FocusZone's +`ref` is the native instance and `commandsRef` exposes +`requestFocus('default' | 'first' | 'last' | target, { signal, timeoutMs })`. +Callout `onReady` supplies an opaque presentation; its handle exposes +`requestFocus(presentation, target)`, `reposition(presentation)`, and +`close(presentation)`. Results are not inferred from void commands. + +The initial close operation does not perform automatic restoration or family +closure. These policies remain for the Menu/Popover stage. Do not treat a +timeout as evidence that no native mutation occurred. + +### Known remaining implementation/qualification + +The first foundation/primitive milestone is implemented, not the entire +Menu/Popover roadmap. Family transactions, safe restore/owner-relative Tab, +text layout providers, modal isolation, actual Menu/Popover integration, +disabled discoverability, and a real module remain. + +Windows helpers/handlers and local rect/point placement are implemented but +uncompiled on macOS. Validate the actual projected SDK, native ownership, +focus result identity and unmount resources. Windows ancestor transform/scroll +tracking still needs full geometry qualification and follow-up; do not infer +it from the current frame-summation positioning path. + +Mac local rect/point stories render, but the full one-logical-unit geometry +matrix, 100-cycle leak test, VoiceOver/field-editor cases, and Paper runtime +lane remain unqualified. The ObjC FocusZone navigation algorithm is retained; +a full Swift port is a separate parity-preserving follow-up. + +## Windows-machine validation + +Run from the repository root after fetching this branch and installing its +declared dependencies: + +```sh +yarn workspace @fluentui-react-native/native-core format --check +yarn workspace @fluentui-react-native/native-core lint +yarn workspace @fluentui-react-native/native-core build +yarn workspace @fluentui-react-native/native-core test +yarn workspace @fluentui-react-native/native-core codegen:windows --check +yarn workspace @fluentui-react-native/callout test +yarn workspace @fluentui-react-native/focus-zone test +yarn build +yarn check-publishing +yarn change:check +``` + +Run native commands from `apps/storybook`: + +```sh +yarn storybook prep --windows +yarn storybook build --windows +yarn storybook bundle --windows +yarn storybook smoke --windows --mode stories +STORYBOOK_SMOKE_STORY='native-modern-focuszone--*' yarn storybook smoke --windows --mode stories-and-tests +yarn storybook bundle --win32 +yarn storybook smoke --win32 --mode stories +``` + +The existing Windows Callout story exclusion must not be removed merely to +claim qualification. Inspect the fresh native library/provider and first +actionable failure. Record exact commands, versions, pass/fail/skip counts, +and whether a case actually exercises the new protocol. + +Windows currently includes the two modern FocusZone stories, not modern +Callout. Win32 includes only legacy native-core stories; modern previews are +not imported and no new managed fields/commands are dispatched there. + +Required scenarios: command-generation mismatch, stale result, abort/unmount, +default/first/last/specific descendant focus, disabled/unfocusable/outside +targets, true focused-component identity, inactive windows, and legacy +navigation/refs. For Callout: actual readiness, close/reopen without an +automatic reopen loop, target replacement, local rect/point placement, +alignment/gap/collision, DPI/RTL/negative monitor origins, and teardown. +Passive observation must not activate a popup to inspect it. + +## Remaining gates + +- Paper runtime/input qualification; the current automation lane targets + Fabric. Paper compilation alone is insufficient. +- Win32 capability evidence for modern operations; do not infer either + support or impossibility from missing observation infrastructure. +- Older React/RN codegen and native build compatibility before changing peer + declarations or adding root exports. +- Disabled-but-discoverable MenuItem policy: focus, activation, and + accessibility-disabled state are separate. +- Natural owner-relative Tab continuation and family close/restoration races. +- Renderer-owned UTF-16 text fragments, truncation/bidi/attachments, and layout + generations. PR #3661's Paper traversal is not a Fabric implementation. +- Modal focus scope and background accessibility isolation; FocusZone cycling + is not a modal contract. +- A representative real TurboModule; no speculative module solely for sharing. + +## Evidence sources + +- Candidate wave-three: + `5f6e7af1252bcfe087dcd1672afaa4ceb7c5efbb`. + It contains implemented macOS family behavior, narrower RNW, and recorded + Menu qualification of 1 passed / 0 failed / 12 skipped. Adapt selectively. +- Historical PR #3661: + `829883482ddd37324e64633126cc86ba08c2a75f`, open/unmerged at research time. +- Preserve the invariants documented in the package README and new component + specifications; this handoff does not grant unverified capabilities. diff --git a/packages/agentic/native-core/src/components/callout/Callout.stories.tsx b/packages/agentic/native-core/src/components/callout/Callout.stories.tsx new file mode 100644 index 00000000000..9f2f71cbb5f --- /dev/null +++ b/packages/agentic/native-core/src/components/callout/Callout.stories.tsx @@ -0,0 +1,147 @@ +/** @jsxImportSource @fluentui-react-native/framework-base */ +import * as React from 'react'; +import { Pressable, Text, View, StyleSheet } from 'react-native'; +import type { Meta, StoryObj } from '@storybook/react-native'; +import type { WdioStory } from '@fluentui-react-native/storybook-desktop/testing'; + +import { Callout } from './Callout'; +import type { CalloutAnchor, CalloutHandle, CalloutDismissedEvent } from './Callout.types'; +import { useNativeViewTarget } from '../nativeTarget'; + +type ExampleProps = { anchorKind: CalloutAnchor['kind']; exerciseCommands?: boolean }; +function Example({ anchorKind, exerciseCommands = false }: ExampleProps) { + const anchor = useNativeViewTarget(); + const child = useNativeViewTarget(); + const handle = React.useRef(null); + const [open, setOpen] = React.useState(true); + const [key, setKey] = React.useState(0); + const [status, setStatus] = React.useState('Opening'); + const [focus, setFocus] = React.useState('No focus'); + const value: CalloutAnchor = + anchorKind === 'view' + ? { kind: 'view', target: anchor.target } + : anchorKind === 'point' + ? { kind: 'point', relativeTo: anchor.target, point: { x: 40, y: 60 } } + : { kind: 'rect', relativeTo: anchor.target, rect: { x: 40, y: 30, width: 60, height: 30 } }; + const operation = async (name: 'close' | 'focus' | 'reposition') => { + const api = handle.current; + const presentation = api?.getPresentation(); + if (!api || !presentation) throw new Error('Callout is not ready.'); + const outcome = + name === 'focus' + ? await api.requestFocus(presentation, child.target) + : name === 'close' + ? await api.close(presentation) + : await api.reposition(presentation); + setStatus(`${name}: ${outcome.status}`); + }; + const exercise = async () => { + const api = handle.current; + const presentation = api?.getPresentation(); + if (!api || !presentation) throw new Error('Callout is not ready.'); + const focused = await api.requestFocus(presentation, child.target); + const positioned = await api.reposition(presentation); + const closed = await api.close(presentation); + setStatus(`${focused.status}/${positioned.status}/${closed.status}`); + }; + return ( + + + {status} + + + {focus} + + + { + setKey((value) => value + 1); + setOpen(true); + setStatus('Opening'); + }} + > + Reopen + + + + Anchor owner + + { + setStatus('Ready'); + if (exerciseCommands && key > 0) void exercise(); + }} + onDismissed={({ reason }: CalloutDismissedEvent) => setStatus(`Dismissed: ${reason}`)} + content={{ style: styles.content, testID: 'modern-callout-content' }} + > + Native popup content + setFocus('Child focused')} + > + Focus target + + + {(['focus', 'reposition', 'close'] as const).map((name) => ( + { + void operation(name); + }} + > + {name} + + ))} + + + + ); +} +const meta: Meta = { title: 'Native/Modern Callout', render: (args) => , args: { anchorKind: 'view' } }; +export default meta; +type Story = WdioStory>; +export const Default: Story = {}; +export const NativeOperations: Story = { + args: { exerciseCommands: true }, + tags: ['desktop-e2e'], + wdio: { + 'readies, confirms descendant focus, closes, and explicitly rearms': async ({ browser, expect, skip }) => { + if (!browser.capabilities['furn:features']?.physicalClick) { + skip('Physical owner input unavailable.'); + return; + } + await expect(await browser.$('~modern-callout-status')).toHaveText('Ready'); + await (await browser.$('~modern-callout-reopen')).click(); + await expect(await browser.$('~modern-callout-status')).toHaveText('confirmed/confirmed/confirmed'); + await expect(await browser.$('~modern-callout-focus-status')).toHaveText('Child focused'); + await browser.waitUntil(async () => (await browser.getWindowHandles()).length === 1, { timeout: 5000 }); + await (await browser.$('~modern-callout-reopen')).click(); + await expect(await browser.$('~modern-callout-status')).toHaveText('confirmed/confirmed/confirmed'); + await browser.waitUntil(async () => (await browser.getWindowHandles()).length === 1, { timeout: 5000 }); + }, + }, +}; +export const LocalRectangle: Story = { args: { anchorKind: 'rect' } }; +export const LocalPoint: Story = { args: { anchorKind: 'point' } }; +const styles = StyleSheet.create({ + story: { padding: 24, gap: 16 }, + row: { flexDirection: 'row', gap: 8 }, + button: { padding: 10, borderWidth: 1, borderColor: '#808080' }, + anchor: { width: 280, height: 100, borderWidth: 1, borderColor: '#808080', padding: 10 }, + content: { width: 320, padding: 16, gap: 12, backgroundColor: '#ffffff' }, +}); diff --git a/packages/agentic/native-core/src/components/callout/Callout.tsx b/packages/agentic/native-core/src/components/callout/Callout.tsx new file mode 100644 index 00000000000..a4ff884d6a9 --- /dev/null +++ b/packages/agentic/native-core/src/components/callout/Callout.tsx @@ -0,0 +1,193 @@ +/** @jsxImportSource @fluentui-react-native/framework-base */ +import * as React from 'react'; +import { I18nManager, View } from 'react-native'; +import { directComponent, phasedComponent, useSlot, mergeProps } from '@fluentui-react-native/framework-base'; + +import { CalloutHost } from '../../internal/CalloutHost'; +import { assertModernHost } from '../../internal/modernHost'; +import { Commands } from '../../specs/components/CalloutNativeComponent'; +import type NativeCallout from '../../specs/components/CalloutNativeComponent'; +import type { DirectionalHint } from '../../legacy/callout/Callout.types'; +import { allocateGeneration, createOperationChannel } from '../nativeOperation'; +import { resolveTarget, useTargetTag } from '../nativeTarget'; +import type { CalloutAnchor, CalloutHandle, CalloutPresentation, CalloutProps, CalloutDismissReason } from './Callout.types'; +import { presentationBrand } from './Callout.types'; + +class Presentation implements CalloutPresentation { + readonly [presentationBrand] = true as const; + private readonly generation: number; + constructor(generation: number) { + this.generation = generation; + } + matches(generation: number) { + return this.generation === generation; + } +} + +function hint(props: CalloutProps): DirectionalHint { + const side = props.placement?.side ?? 'bottom'; + const align = props.placement?.align ?? 'start'; + const rtl = I18nManager.isRTL; + const physical = side === 'start' ? (rtl ? 'right' : 'left') : side === 'end' ? (rtl ? 'left' : 'right') : side; + if (physical === 'left' || physical === 'right') { + return `${physical}${align === 'center' ? 'Center' : align === 'end' ? 'BottomEdge' : 'TopEdge'}`; + } + const edge = align === 'center' ? 'Center' : (align === 'start') !== rtl ? 'LeftEdge' : 'RightEdge'; + return `${physical}${edge}`; +} + +function rectangle(anchor: CalloutAnchor) { + if (anchor.kind === 'view') return undefined; + const rect = anchor.kind === 'rect' ? anchor.rect : { ...anchor.point, width: 0, height: 0 }; + if (![rect.x, rect.y, rect.width, rect.height].every(Number.isFinite) || rect.width < 0 || rect.height < 0) { + throw new RangeError('Callout anchor geometry must contain finite coordinates and nonnegative dimensions.'); + } + return { screenX: rect.x, screenY: rect.y, width: rect.width, height: rect.height }; +} + +const reasons: readonly string[] = ['native-light-dismiss', 'programmatic', 'anchor-lost', 'host-detached']; +function isReason(value: string): value is CalloutDismissReason { + return reasons.includes(value); +} + +function CalloutImplementation(props: CalloutProps) { + assertModernHost('Callout'); + const target = props.anchor.kind === 'view' ? props.anchor.target : props.anchor.relativeTo; + const tag = useTargetTag(target); + const nativeRef = React.useRef>(null); + const channel = React.useMemo(createOperationChannel, []); + const [lifetime, setLifetime] = React.useState(() => ({ + open: props.open, + key: props.presentationKey, + generation: allocateGeneration(), + closed: false, + anchored: false, + })); + if (lifetime.open !== props.open || lifetime.key !== props.presentationKey) { + const rearm = (!lifetime.open && props.open) || lifetime.key !== props.presentationKey; + setLifetime({ + open: props.open, + key: props.presentationKey, + generation: rearm ? allocateGeneration() : lifetime.generation, + closed: rearm ? false : lifetime.closed, + anchored: rearm ? tag !== undefined : lifetime.anchored, + }); + } else if (props.open && !lifetime.closed) { + if (tag !== undefined && !lifetime.anchored) setLifetime({ ...lifetime, anchored: true }); + else if (tag === undefined && lifetime.anchored) setLifetime({ ...lifetime, closed: true }); + } + const { generation, closed } = lifetime; + const presentation = React.useMemo(() => new Presentation(generation), [generation]); + const committedGeneration = React.useRef(generation); + const ready = React.useRef(null); + const Content = useSlot(View, { collapsable: false }); + + React.useLayoutEffect(() => { + if (committedGeneration.current !== generation || !props.open || closed || tag === undefined) ready.current = null; + committedGeneration.current = generation; + if (props.open && !closed && tag !== undefined) channel.activate(generation); + else channel.cancel(); + return channel.cancel; + }, [channel, generation, props.open, closed, tag]); + + React.useImperativeHandle( + props.ref, + (): CalloutHandle => { + const request = ( + expected: CalloutPresentation, + dispatch: (scope: number, id: number) => void, + options?: Parameters[1], + current?: () => boolean, + ) => { + if (!nativeRef.current) return Promise.resolve({ status: 'not-mounted' as const }); + if (expected !== ready.current) return Promise.resolve({ status: ready.current ? ('stale' as const) : ('not-ready' as const) }); + return channel.request(dispatch, options, current); + }; + return { + getPresentation: () => ready.current, + requestFocus(expected, target, options) { + const resolved = resolveTarget(target); + if (resolved.tag == null) return Promise.resolve({ status: 'not-mounted' }); + return request( + expected, + (scope, id) => { + if (!nativeRef.current) throw new Error('Callout detached before focus dispatch.'); + Commands.requestFocus(nativeRef.current, scope, id, resolved.tag); + }, + options, + resolved.isCurrent, + ); + }, + close: (expected, options) => + request( + expected, + (scope, id) => { + if (!nativeRef.current) throw new Error('Callout detached before close dispatch.'); + Commands.close(nativeRef.current, scope, id); + }, + options, + ), + reposition: (expected, options) => + request( + expected, + (scope, id) => { + if (!nativeRef.current) throw new Error('Callout detached before reposition dispatch.'); + Commands.reposition(nativeRef.current, scope, id); + }, + options, + ), + }; + }, + [channel], + ); + + const gap = props.placement?.gap ?? 0; + if (!Number.isInteger(gap) || gap < 0) throw new RangeError('Callout gap must be a nonnegative integer in logical units.'); + const rect = rectangle(props.anchor); + + { + const renderProps = props; + if (!renderProps.open || closed || tag === undefined) return null; + return ( + { + if ( + event.nativeEvent.generation !== committedGeneration.current || + !presentation.matches(event.nativeEvent.generation) || + ready.current + ) + return; + ready.current = presentation; + renderProps.onReady?.({ presentation }); + }} + onClosed={(event) => { + if (event.nativeEvent.generation !== committedGeneration.current || !presentation.matches(event.nativeEvent.generation)) return; + if (!isReason(event.nativeEvent.reason)) throw new Error('Callout received an invalid native dismissal reason.'); + ready.current = null; + channel.cancel(); + setLifetime((current) => (current.generation === event.nativeEvent.generation ? { ...current, closed: true } : current)); + renderProps.onDismissed?.({ presentation, reason: event.nativeEvent.reason }); + }} + onOperationResult={(event) => channel.receive(event.nativeEvent)} + > + + {renderProps.children} + + + ); + } +} + +export const Callout = phasedComponent((baseProps) => + directComponent((props) => ), +); + +Callout.displayName = 'Callout'; diff --git a/packages/agentic/native-core/src/components/callout/Callout.types.ts b/packages/agentic/native-core/src/components/callout/Callout.types.ts new file mode 100644 index 00000000000..2be15a578c6 --- /dev/null +++ b/packages/agentic/native-core/src/components/callout/Callout.types.ts @@ -0,0 +1,48 @@ +import type * as React from 'react'; +import type { View, ViewProps } from 'react-native'; +import type { NativeViewTarget } from '../nativeTarget'; +import type { NativeOperationOptions, NativeOperationOutcome } from '../nativeOperation'; + +export const presentationBrand = Symbol('native-core.presentation'); +export interface CalloutPresentation { + readonly [presentationBrand]: true; +} +export type CalloutAnchor = + | { kind: 'view'; target: NativeViewTarget } + | { kind: 'rect'; relativeTo: NativeViewTarget; rect: { x: number; y: number; width: number; height: number } } + | { kind: 'point'; relativeTo: NativeViewTarget; point: { x: number; y: number } }; + +export interface CalloutHandle { + getPresentation(): CalloutPresentation | null; + requestFocus( + presentation: CalloutPresentation, + target: NativeViewTarget, + options?: NativeOperationOptions, + ): Promise; + close(presentation: CalloutPresentation, options?: NativeOperationOptions): Promise; + reposition(presentation: CalloutPresentation, options?: NativeOperationOptions): Promise; +} + +export type CalloutDismissReason = 'native-light-dismiss' | 'programmatic' | 'anchor-lost' | 'host-detached'; +export interface CalloutReadyEvent { + presentation: CalloutPresentation; +} +export interface CalloutDismissedEvent extends CalloutReadyEvent { + reason: CalloutDismissReason; +} + +export interface CalloutProps { + ref?: React.Ref; + open: boolean; + presentationKey?: string; + anchor: CalloutAnchor; + placement?: { + side?: 'top' | 'bottom' | 'start' | 'end' | 'left' | 'right'; + align?: 'start' | 'center' | 'end'; + gap?: number; + }; + children?: React.ReactNode; + content?: Omit & { ref?: React.Ref> }; + onReady?: (event: CalloutReadyEvent) => void; + onDismissed?: (event: CalloutDismissedEvent) => void; +} diff --git a/packages/agentic/native-core/src/components/callout/SPEC.md b/packages/agentic/native-core/src/components/callout/SPEC.md new file mode 100644 index 00000000000..2a260ad0cad --- /dev/null +++ b/packages/agentic/native-core/src/components/callout/SPEC.md @@ -0,0 +1,31 @@ +# Modern Callout + +Owner-authorized initial contract, 2026-10-02. `/macos` and `/windows` preview +only; not all-host root admission. See `plans/modernization.md` for executed +evidence and deferred Menu/Popover, Paper, Win32, and peer-floor gates. + +Callout is unstyled. Its React 19 ref is a presentation handle, deliberately +not the hidden native marker. `content` forwards appearance, accessibility, +events and an actual View ref to one materialized attachment host; arbitrary +host replacement/collapse is not allowed. + +`open` is externally requested presence. Native closure reports onDismissed +and latches that generation closed. Retaining open=true does not reopen. +False->true or a changed presentationKey rearms. onReady means attached content +and valid positioning, not child-focus confirmation. A missing live anchor +does not present at the origin. + +View anchors track committed attachment; rect/point anchors are snapshots in +local logical units relative to their live owner. No public numeric tags, +global screen coordinate guesses, or inline Text/shadow-tree geometry. +Placement resolves logical start/end, alignment, gap, flip and work-area shift. + +Handle operations require the current opaque presentation. Native events, +not void command returns, confirm focus/close/reposition. Abort, target changes, +unmount, invalid generation and timeout produce explicit outcomes. Timeout is +unconfirmed; cancellation cannot undo an already-executed native mutation. +Focus remains scoped to eligible content and active owned windows. + +Request-only veto, precise light-dismiss classification, automatic restoration, +popup-family transactions, modal containment and text fragments are deferred; +no success-shaped substitutes are provided. diff --git a/packages/agentic/native-core/src/components/focus-zone/FocusZone.stories.tsx b/packages/agentic/native-core/src/components/focus-zone/FocusZone.stories.tsx new file mode 100644 index 00000000000..ed4968a23aa --- /dev/null +++ b/packages/agentic/native-core/src/components/focus-zone/FocusZone.stories.tsx @@ -0,0 +1,114 @@ +/** @jsxImportSource @fluentui-react-native/framework-base */ +import * as React from 'react'; +import { Pressable, Text, View, StyleSheet } from 'react-native'; +import type { Meta, StoryObj } from '@storybook/react-native'; +import type { WdioStory } from '@fluentui-react-native/storybook-desktop/testing'; + +import { FocusZone } from './FocusZone'; +import type { FocusZoneCommands, FocusZoneProps } from './FocusZone.types'; +import { useNativeViewTarget } from '../nativeTarget'; + +function Example(props: FocusZoneProps) { + const commands = React.useRef(null); + const target = useNativeViewTarget(); + const [status, setStatus] = React.useState('Idle'); + const request = async (destination: 'default' | 'first' | 'last' | 'target') => { + if (!commands.current) throw new Error('FocusZone commands are not attached.'); + const outcome = await commands.current.requestFocus(destination === 'target' ? target.target : destination); + setStatus(outcome.status); + }; + return ( + + + {status} + + + {(['first', 'last', 'default', 'target'] as const).map((strategy) => ( + { + void request(strategy); + }} + > + {strategy} + + ))} + + + {[1, 2, 3].map((index) => ( + + {index} + + ))} + + + ); +} +const meta: Meta = { + title: 'Native/Modern FocusZone', + component: FocusZone, + render: (args) => , +}; +export default meta; +type Story = WdioStory>; +export const Default: Story = { + tags: ['desktop-e2e'], + wdio: { + 'confirms native default, edge, and specific descendant requests': async ({ browser, expect, skip }) => { + if (!browser.capabilities['furn:features']?.physicalClick) { + skip('Native commands require physical input for their owner controls.'); + return; + } + for (const [strategy, item] of [ + ['first', 1], + ['last', 3], + ['default', 2], + ['target', 2], + ] as const) { + await (await browser.$(`~modern-focus-request-${strategy}`)).click(); + await expect(await browser.$('~modern-focus-status')).toHaveText('confirmed'); + await browser.waitUntil(async () => (await (await browser.$(`~modern-focus-item-${item}`)).getProperty('focused')) === true, { + timeout: 5000, + timeoutMsg: `Expected actual focus on item ${item}.`, + }); + } + }, + }, +}; +export const Disabled: Story = { + args: { disabled: true }, + wdio: { + 'refuses a disabled-zone request': async ({ browser, expect, skip }) => { + if (!browser.capabilities['furn:features']?.physicalClick) { + skip('Physical input unavailable.'); + return; + } + await (await browser.$('~modern-focus-request-first')).click(); + await expect(await browser.$('~modern-focus-status')).toHaveText('not-focusable'); + }, + }, +}; +const styles = StyleSheet.create({ + story: { padding: 24, gap: 16 }, + row: { flexDirection: 'row', gap: 12 }, + button: { padding: 12, borderWidth: 1, borderColor: '#808080' }, +}); diff --git a/packages/agentic/native-core/src/components/focus-zone/FocusZone.tsx b/packages/agentic/native-core/src/components/focus-zone/FocusZone.tsx new file mode 100644 index 00000000000..1c06393a711 --- /dev/null +++ b/packages/agentic/native-core/src/components/focus-zone/FocusZone.tsx @@ -0,0 +1,82 @@ +/** @jsxImportSource @fluentui-react-native/framework-base */ +import * as React from 'react'; +import { directComponent, phasedComponent, mergeProps } from '@fluentui-react-native/framework-base'; + +import { FocusZoneHost } from '../../internal/FocusZoneHost'; +import { assertModernHost } from '../../internal/modernHost'; +import type NativeFocusZone from '../../specs/components/FocusZoneNativeComponent'; +import { FocusCommands as Commands } from '../../internal/focusZoneCommands'; +import { allocateGeneration, createOperationChannel } from '../nativeOperation'; +import { resolveTarget, useTargetTag } from '../nativeTarget'; +import type { FocusZoneCommands, FocusZoneProps } from './FocusZone.types'; + +const tabModes = { exit: 'None', native: 'Normal', cycle: 'NavigateWrap', stop: 'NavigateStopAtEnds' } as const; + +function FocusZoneImplementation(props: FocusZoneProps) { + assertModernHost('FocusZone'); + const nativeRef = React.useRef>(null); + const channel = React.useMemo(createOperationChannel, []); + const generation = React.useMemo(allocateGeneration, []); + const defaultTag = useTargetTag(props.defaultTarget); + + React.useLayoutEffect(() => { + channel.activate(generation); + return channel.cancel; + }, [channel, generation]); + + React.useImperativeHandle( + props.commandsRef, + (): FocusZoneCommands => ({ + requestFocus(target = 'default', options) { + if (!nativeRef.current) return Promise.resolve({ status: 'not-mounted' }); + if (props.disabled) return Promise.resolve({ status: 'not-focusable' }); + const resolved = typeof target === 'string' ? undefined : resolveTarget(target); + if (resolved && resolved.tag == null) return Promise.resolve({ status: 'not-mounted' }); + const strategy = typeof target === 'string' ? target : 'target'; + return channel.request( + (scope, requestId) => { + const view = nativeRef.current; + if (!view) throw new Error('FocusZone detached before command dispatch.'); + Commands.requestFocus(view, scope, requestId, resolved?.tag ?? 0, strategy); + }, + options, + resolved?.isCurrent, + ); + }, + }), + [channel, props.disabled], + ); + + { + const { + ref, + commandsRef: _commands, + defaultTarget: _target, + direction = 'both', + navigation = 'platform', + arrowBoundary = 'stop', + tabNavigation = 'exit', + ...viewProps + } = props; + return ( + channel.receive(event.nativeEvent)} + nativeDefaultTarget={defaultTag} + focusZoneDirection={direction === 'both' ? 'bidirectional' : direction} + use2DNavigation={navigation === 'spatial'} + navigateAtEnd={arrowBoundary === 'wrap' ? 'NavigateWrap' : 'NavigateStopAtEnds'} + tabKeyNavigation={tabModes[tabNavigation]} + /> + ); + } +} + +export const FocusZone = phasedComponent((baseProps) => + directComponent((props) => ), +); + +FocusZone.displayName = 'FocusZone'; diff --git a/packages/agentic/native-core/src/components/focus-zone/FocusZone.types.ts b/packages/agentic/native-core/src/components/focus-zone/FocusZone.types.ts new file mode 100644 index 00000000000..a00356709b3 --- /dev/null +++ b/packages/agentic/native-core/src/components/focus-zone/FocusZone.types.ts @@ -0,0 +1,22 @@ +import type * as React from 'react'; +import type { ViewProps } from 'react-native'; +import type NativeFocusZone from '../../specs/components/FocusZoneNativeComponent'; +import type { NativeViewTarget } from '../nativeTarget'; +import type { NativeOperationOptions, NativeOperationOutcome } from '../nativeOperation'; + +export type FocusZoneHandle = React.ComponentRef; + +export interface FocusZoneCommands { + requestFocus(target?: 'default' | 'first' | 'last' | NativeViewTarget, options?: NativeOperationOptions): Promise; +} + +export interface FocusZoneProps extends ViewProps { + ref?: React.Ref; + commandsRef?: React.Ref; + disabled?: boolean; + direction?: 'none' | 'horizontal' | 'vertical' | 'both'; + navigation?: 'platform' | 'spatial'; + arrowBoundary?: 'stop' | 'wrap'; + tabNavigation?: 'exit' | 'native' | 'cycle' | 'stop'; + defaultTarget?: NativeViewTarget; +} diff --git a/packages/agentic/native-core/src/components/focus-zone/SPEC.md b/packages/agentic/native-core/src/components/focus-zone/SPEC.md new file mode 100644 index 00000000000..cfdeebbd524 --- /dev/null +++ b/packages/agentic/native-core/src/components/focus-zone/SPEC.md @@ -0,0 +1,24 @@ +# Modern FocusZone + +Owner-authorized initial contract, 2026-10-02. Native-core `/macos` and +`/windows` preview only; runtime qualification and old peer floors remain +tracked in `plans/modernization.md`. This is not root/Win32 admission. + +The component is unstyled and preserves the actual native-zone instance in +the React 19 `ref` prop. `commandsRef` publishes a separate mounted command +surface; it is null after unmount. Both native and caller refs compose through +framework-base slots, including callback cleanup. + +Direction, spatial opt-in, arrow wrap, Tab mode, disabled, and a live +defaultTarget translate to the existing per-host navigation engine. +Navigation remains platform-native by default; it is not modal containment. + +`requestFocus` supports default/first/last or a live scoped descendant. +Confirmation comes from native responder/focused-component identity, not a +void focus return. Requests are generation-fenced, cancellable, bounded by +an explicit timeout, and invalidated on component/target replacement. +Missing mounts are not queued; inactive windows are not reactivated. +Cancellation cannot reverse a mutation already executed natively. + +Disabled discoverability, remembered focus, natural cross-popup Tab +continuation, and all-host root behavior are separate admission gates. diff --git a/packages/agentic/native-core/src/components/modern.test.tsx b/packages/agentic/native-core/src/components/modern.test.tsx new file mode 100644 index 00000000000..bfb0af441bb --- /dev/null +++ b/packages/agentic/native-core/src/components/modern.test.tsx @@ -0,0 +1,192 @@ +/** @jsxImportSource @fluentui-react-native/framework-base */ +import * as React from 'react'; +import { Platform, View, Text } from 'react-native'; +import { act, fireEvent, render } from '@testing-library/react-native'; +import { useSlot } from '@fluentui-react-native/framework-base'; + +import { Callout } from './callout/Callout'; +import type { CalloutHandle, CalloutPresentation } from './callout/Callout.types'; +import { FocusZone } from './focus-zone/FocusZone'; +import type { FocusZoneCommands, FocusZoneHandle } from './focus-zone/FocusZone.types'; +import { useNativeViewTarget, resolveTarget } from './nativeTarget'; +import { FocusCommands } from '../internal/focusZoneCommands'; +import { Commands as CalloutCommands } from '../specs/components/CalloutNativeComponent'; + +jest.mock('react-native/Libraries/ReactNative/RendererProxy', () => ({ + ...jest.requireActual('react-native/Libraries/ReactNative/RendererProxy'), + findNodeHandle: jest.fn(() => 42), +})); + +function calloutRoot(component: Awaited>) { + let root = component.getByTestId('content').parent; + while (root && !root.props.commandGeneration) root = root.parent; + if (!root) throw new Error('Missing native Callout ancestor'); + return root; +} + +describe('modern native facades', () => { + beforeEach(() => jest.replaceProperty(Platform, 'OS', 'macos')); + afterEach(() => jest.restoreAllMocks()); + + it('preserves the native FocusZone ref and confirms the separate command from a result event', async () => { + const nativeRef = React.createRef(); + const commandsRef = React.createRef(); + const dispatch = jest.spyOn(FocusCommands, 'requestFocus').mockImplementation(() => undefined); + const component = await render( + + + , + ); + expect(nativeRef.current).not.toBeNull(); + expect(commandsRef.current).not.toBeNull(); + const root = component.getByTestId('zone'); + let result: ReturnType; + await act(async () => { + result = commandsRef.current.requestFocus('first'); + }); + expect(dispatch).toHaveBeenCalledTimes(1); + const [, generation, requestId, , strategy] = dispatch.mock.calls[0]; + expect(strategy).toBe('first'); + await fireEvent(root, 'operationResult', { nativeEvent: { generation, requestId, status: 'confirmed' } }); + expect(await result).toEqual({ status: 'confirmed' }); + const old = commandsRef.current; + await component.unmount(); + expect(nativeRef.current).toBeNull(); + expect(commandsRef.current).toBeNull(); + expect(await old.requestFocus()).toEqual({ status: 'not-mounted' }); + }); + + it('keeps live attachments generation-safe through same-node handoff and stale cleanup', async () => { + let binding: ReturnType; + const node = React.createRef(); + function Fixture() { + binding = useNativeViewTarget(); + return ; + } + const component = await render(); + await act(async () => { + const firstCleanup = binding.ref(node.current); + const captured = resolveTarget(binding.target); + const secondCleanup = binding.ref(node.current); + if (typeof firstCleanup !== 'function' || typeof secondCleanup !== 'function') throw new Error('Missing callback cleanup'); + firstCleanup(); + expect(captured.isCurrent()).toBe(true); + secondCleanup(); + expect(captured.isCurrent()).toBe(false); + }); + await component.unmount(); + }); + + it('exposes readiness, latches physical close, and ignores old-generation events after rearm', async () => { + const ref = React.createRef(); + const ready = jest.fn(); + const dismissed = jest.fn(); + function Fixture({ open, rearm = 'one' }: { open: boolean; rearm?: string }) { + const anchor = useNativeViewTarget(); + return ( + <> + + + Child + + + ); + } + const component = await render(); + const root = calloutRoot(component); + const oldClosed = root.props.onClosed; + const oldGeneration = root.props.commandGeneration; + expect(ref.current.getPresentation()).toBeNull(); + await fireEvent(root, 'ready', { nativeEvent: { generation: root.props.commandGeneration } }); + const oldPresentation = ref.current.getPresentation(); + expect(oldPresentation).not.toBeNull(); + expect(ready).toHaveBeenCalledTimes(1); + await fireEvent(root, 'closed', { nativeEvent: { generation: root.props.commandGeneration, reason: 'native-light-dismiss' } }); + expect(component.queryByTestId('content')).toBeNull(); + await component.rerender(); + expect(component.queryByTestId('content')).toBeNull(); + await component.rerender(); + const next = calloutRoot(component); + expect(next.props.commandGeneration).not.toBe(oldGeneration); + await act(async () => oldClosed({ nativeEvent: { generation: oldGeneration, reason: 'programmatic' } })); + expect(component.getByTestId('content')).toBeTruthy(); + expect(dismissed).toHaveBeenCalledTimes(1); + expect(await ref.current.close(oldPresentation)).toEqual({ status: 'not-ready' }); + await component.unmount(); + }); + + it('settles a close result before invalidating the presentation and forwards local point geometry', async () => { + const ref = React.createRef(); + const dispatch = jest.spyOn(CalloutCommands, 'close').mockImplementation(() => undefined); + function Fixture() { + const anchor = useNativeViewTarget(); + return ( + <> + + + + + + ); + } + const component = await render(); + const root = calloutRoot(component); + expect(root.props.anchorRect).toEqual({ screenX: 3, screenY: 7, width: 0, height: 0 }); + await fireEvent(root, 'ready', { nativeEvent: { generation: root.props.commandGeneration } }); + const presentation: CalloutPresentation = ref.current.getPresentation(); + let result: ReturnType; + await act(async () => { + result = ref.current.close(presentation); + }); + const [, generation, requestId] = dispatch.mock.calls[0]; + await fireEvent(root, 'operationResult', { nativeEvent: { generation, requestId, status: 'confirmed' } }); + await fireEvent(root, 'closed', { nativeEvent: { generation, reason: 'programmatic' } }); + expect(await result).toEqual({ status: 'confirmed' }); + await component.unmount(); + }); + + it('honors refs, live defaults, and lifecycle props supplied at slot render time', async () => { + const commands = React.createRef(); + const native = React.createRef(); + const presentation = React.createRef(); + function Fixture({ open }: { open: boolean }) { + const target = useNativeViewTarget(); + const Zone = useSlot(FocusZone, {}); + const Popup = useSlot(Callout, {}); + return ( + <> + + + + + + + + ); + } + const component = await render(); + expect(native.current).not.toBeNull(); + expect(commands.current).not.toBeNull(); + expect(presentation.current).not.toBeNull(); + expect(component.getByTestId('zone').props.defaultTabbableElement).toBe(42); + const first = calloutRoot(component).props.commandGeneration; + await component.rerender(); + expect(component.queryByTestId('content')).toBeNull(); + await component.rerender(); + expect(calloutRoot(component).props.commandGeneration).not.toBe(first); + await component.unmount(); + }); +}); diff --git a/packages/agentic/native-core/src/components/modern.types.test.ts b/packages/agentic/native-core/src/components/modern.types.test.ts new file mode 100644 index 00000000000..6e6f419f41d --- /dev/null +++ b/packages/agentic/native-core/src/components/modern.types.test.ts @@ -0,0 +1,24 @@ +import type * as React from 'react'; +import type { SlotProp } from '@fluentui-react-native/framework-base'; +import type { CalloutProps, CalloutHandle, FocusZoneProps, FocusZoneHandle, NativeViewTarget } from '../macos'; + +type Assert = T; +type LegacyCalloutFields = Extract; +type LegacyFocusFields = Extract; +type NoLegacyCallout = Assert; +type NoLegacyFocus = Assert; +type ModernCallout = typeof import('./callout/Callout').Callout; +type ModernFocusZone = typeof import('./focus-zone/FocusZone').FocusZone; + +function contracts(target: NativeViewTarget, calloutRef: React.Ref, zoneRef: React.Ref) { + const calloutSlot: SlotProp = { open: true, anchor: { kind: 'view', target }, ref: calloutRef }; + const zoneSlot: SlotProp = { direction: 'vertical', ref: zoneRef }; + const noLegacyCallout: NoLegacyCallout = true; + const noLegacyFocus: NoLegacyFocus = true; + return { calloutSlot, zoneSlot, noLegacyCallout, noLegacyFocus }; +} +void contracts; + +it('checks modern refs, slots and legacy-field exclusion', () => { + expect(true).toBe(true); +}); diff --git a/packages/agentic/native-core/src/components/nativeOperation.test.ts b/packages/agentic/native-core/src/components/nativeOperation.test.ts new file mode 100644 index 00000000000..75b20db1dae --- /dev/null +++ b/packages/agentic/native-core/src/components/nativeOperation.test.ts @@ -0,0 +1,65 @@ +import { allocateGeneration, createOperationChannel } from './nativeOperation'; + +describe('native operation lifetime', () => { + afterEach(() => jest.useRealTimers()); + + it('does not queue before attachment and confirms only correlated native results', async () => { + const channel = createOperationChannel(); + const dispatch = jest.fn(); + expect(await channel.request(dispatch)).toEqual({ status: 'not-mounted' }); + expect(dispatch).not.toHaveBeenCalled(); + channel.activate(allocateGeneration()); + const result = channel.request(dispatch); + const [generation, requestId] = dispatch.mock.calls[0]; + channel.receive({ generation: generation + 1, requestId, status: 'confirmed' }); + channel.receive({ generation, requestId, status: 'confirmed' }); + expect(await result).toEqual({ status: 'confirmed' }); + }); + + it('cancels on detach and ignores stale results after replacement', async () => { + const channel = createOperationChannel(); + const dispatch = jest.fn(); + channel.activate(allocateGeneration()); + const old = channel.request(dispatch); + const [generation, requestId] = dispatch.mock.calls[0]; + channel.cancel(); + channel.activate(allocateGeneration()); + channel.receive({ generation, requestId, status: 'confirmed' }); + expect(await old).toEqual({ status: 'cancelled' }); + }); + + it('supports abort, target replacement, and explicit unconfirmed timeouts', async () => { + jest.useFakeTimers(); + const channel = createOperationChannel(); + channel.activate(allocateGeneration()); + const controller = new AbortController(); + const aborted = channel.request(jest.fn(), { signal: controller.signal }); + controller.abort(); + expect(await aborted).toEqual({ status: 'cancelled' }); + const dispatch = jest.fn(); + const stale = channel.request(dispatch, {}, () => false); + const [generation, requestId] = dispatch.mock.calls[0]; + channel.receive({ generation, requestId, status: 'confirmed' }); + expect(await stale).toEqual({ status: 'cancelled' }); + const timeout = channel.request(jest.fn(), { timeoutMs: 10 }); + jest.advanceTimersByTime(10); + expect(await timeout).toEqual({ status: 'timed-out' }); + }); + + it('surfaces malformed results and dispatch failures', async () => { + const channel = createOperationChannel(); + channel.activate(allocateGeneration()); + const dispatch = jest.fn(); + const result = channel.request(dispatch); + const rejected = expect(result).rejects.toThrow('invalid operation status'); + const [generation, requestId] = dispatch.mock.calls[0]; + expect(() => channel.receive({ generation, requestId, status: 'success-ish' })).toThrow('invalid operation status'); + await rejected; + await expect( + channel.request(() => { + throw new Error('transport failed'); + }), + ).rejects.toThrow('transport failed'); + expect(() => channel.request(jest.fn(), { timeoutMs: NaN })).toThrow(RangeError); + }); +}); diff --git a/packages/agentic/native-core/src/components/nativeOperation.ts b/packages/agentic/native-core/src/components/nativeOperation.ts new file mode 100644 index 00000000000..cbcabac6add --- /dev/null +++ b/packages/agentic/native-core/src/components/nativeOperation.ts @@ -0,0 +1,124 @@ +export type NativeOperationStatus = + | 'confirmed' + | 'cancelled' + | 'not-mounted' + | 'not-ready' + | 'not-focusable' + | 'inactive-window' + | 'stale' + | 'refused' + | 'unsupported' + | 'timed-out'; + +export interface NativeOperationOutcome { + status: NativeOperationStatus; +} + +export interface NativeOperationOptions { + signal?: AbortSignal; + timeoutMs?: number; +} + +export interface NativeOperationResult { + generation: number; + requestId: number; + status: string; +} + +let nextGeneration = 0; +export function allocateGeneration(): number { + if (nextGeneration === 2147483647) throw new Error('Native-core exhausted its command generation space.'); + return ++nextGeneration; +} + +const statuses: readonly string[] = [ + 'confirmed', + 'cancelled', + 'not-mounted', + 'not-ready', + 'not-focusable', + 'inactive-window', + 'stale', + 'refused', + 'unsupported', + 'timed-out', +]; +function isStatus(value: string): value is NativeOperationStatus { + return statuses.includes(value); +} + +interface Pending { + finish(outcome: NativeOperationOutcome): void; + fail(error: Error): void; +} + +export function createOperationChannel() { + let generation: number | undefined; + let nextRequest = 0; + const pending = new Map(); + const cancel = () => { + generation = undefined; + Array.from(pending.values()).forEach((request) => request.finish({ status: 'cancelled' })); + }; + return { + activate(value: number) { + if (generation !== value) { + cancel(); + generation = value; + } + }, + cancel, + receive(result: NativeOperationResult) { + if (result.generation !== generation) return; + const request = pending.get(result.requestId); + if (!request) return; + if (!isStatus(result.status)) { + const error = new Error(`Native-core received an invalid operation status: ${result.status}`); + request.fail(error); + throw error; + } + request.finish({ status: result.status }); + }, + request( + dispatch: (generation: number, requestId: number) => void, + options: NativeOperationOptions = {}, + isCurrent: () => boolean = () => true, + ): Promise { + const timeout = options.timeoutMs ?? 5000; + if (!Number.isFinite(timeout) || timeout <= 0) throw new RangeError('Native operation timeoutMs must be positive and finite.'); + if (options.signal?.aborted) return Promise.resolve({ status: 'cancelled' }); + if (generation === undefined) return Promise.resolve({ status: 'not-mounted' }); + if (nextRequest === 2147483647) throw new Error('Native-core exhausted its request ID space.'); + const requestId = ++nextRequest; + const capturedGeneration = generation; + return new Promise((resolve, reject) => { + const cleanup = () => { + clearTimeout(timer); + options.signal?.removeEventListener('abort', abort); + pending.delete(requestId); + }; + const finish = (outcome: NativeOperationOutcome) => { + if (!pending.has(requestId)) return; + cleanup(); + resolve(isCurrent() ? outcome : { status: 'cancelled' }); + }; + const abort = () => finish({ status: 'cancelled' }); + const timer = setTimeout(() => finish({ status: 'timed-out' }), timeout); + pending.set(requestId, { + finish, + fail(error) { + cleanup(); + reject(error); + }, + }); + options.signal?.addEventListener('abort', abort, { once: true }); + try { + dispatch(capturedGeneration, requestId); + } catch (error) { + cleanup(); + reject(error); + } + }); + }, + }; +} diff --git a/packages/agentic/native-core/src/components/nativeTarget.ts b/packages/agentic/native-core/src/components/nativeTarget.ts new file mode 100644 index 00000000000..b38482a2a46 --- /dev/null +++ b/packages/agentic/native-core/src/components/nativeTarget.ts @@ -0,0 +1,104 @@ +import * as React from 'react'; +import { findNodeHandle } from 'react-native'; +import type { View } from 'react-native'; + +const targetBrand = Symbol('native-core.target'); + +export interface NativeViewTarget { + readonly [targetBrand]: true; +} + +type ViewInstance = React.ComponentRef; +type Snapshot = Readonly<{ current: ViewInstance | null; generation: number }>; +const emptySnapshot: Snapshot = Object.freeze({ current: null, generation: 0 }); + +class Target implements NativeViewTarget { + readonly [targetBrand] = true as const; + private snapshot = emptySnapshot; + private registration = 0; + private detached: Snapshot | undefined; + private listeners = new Set<() => void>(); + + getSnapshot = () => this.snapshot; + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + private publish() { + Array.from(this.listeners).forEach((listener) => listener()); + } + + attach(instance: ViewInstance) { + const previous = this.detached ?? this.snapshot; + this.detached = undefined; + const ticket = ++this.registration; + if (previous.current === instance) { + this.snapshot = previous; + } else { + this.snapshot = Object.freeze({ current: instance, generation: previous.generation + 1 }); + this.publish(); + } + return () => { + if (ticket !== this.registration) return; + ++this.registration; + const detached = this.snapshot; + this.detached = detached; + this.snapshot = Object.freeze({ current: null, generation: detached.generation }); + queueMicrotask(() => { + if (this.detached !== detached) return; + this.detached = undefined; + this.snapshot = Object.freeze({ current: null, generation: detached.generation + 1 }); + this.publish(); + }); + }; + } +} + +function implementation(target: NativeViewTarget): Target { + if (!(target instanceof Target)) throw new TypeError('Expected a target created by useNativeViewTarget.'); + return target; +} + +export function useNativeViewTarget(): Readonly<{ target: NativeViewTarget; ref: React.RefCallback }> { + return React.useMemo(() => { + const target = new Target(); + let detach: (() => void) | undefined; + const ref: React.RefCallback = (instance) => { + if (instance) { + detach = target.attach(instance); + return detach; + } + detach?.(); + detach = undefined; + return undefined; + }; + return { target, ref }; + }, []); +} + +const subscribeEmpty = () => () => undefined; +const getEmpty = () => emptySnapshot; + +export function useTargetSnapshot(target?: NativeViewTarget): Snapshot { + const store = target ? implementation(target) : undefined; + return React.useSyncExternalStore(store?.subscribe ?? subscribeEmpty, store?.getSnapshot ?? getEmpty, getEmpty); +} + +export function resolveTarget(target: NativeViewTarget) { + const store = implementation(target); + const snapshot = store.getSnapshot(); + const tag = snapshot.current ? findNodeHandle(snapshot.current) : null; + return { + tag, + isCurrent: () => store.getSnapshot().current === snapshot.current && store.getSnapshot().generation === snapshot.generation, + }; +} + +export function useTargetTag(target?: NativeViewTarget): number | undefined { + const snapshot = useTargetSnapshot(target); + const [tag, setTag] = React.useState(); + React.useLayoutEffect(() => { + setTag(snapshot.current ? (findNodeHandle(snapshot.current) ?? undefined) : undefined); + }, [snapshot]); + return tag; +} diff --git a/packages/agentic/native-core/src/internal/CalloutHost.tsx b/packages/agentic/native-core/src/internal/CalloutHost.tsx new file mode 100644 index 00000000000..99a5cafd72c --- /dev/null +++ b/packages/agentic/native-core/src/internal/CalloutHost.tsx @@ -0,0 +1,131 @@ +/** @jsxImportSource @fluentui-react-native/framework-base */ +import * as React from 'react'; +import { findNodeHandle, StyleSheet } from 'react-native'; + +import { directComponent, mergeProps, phasedComponent, useSlot } from '@fluentui-react-native/framework-base'; + +import type { CalloutProps } from '../legacy/callout/Callout.types'; +import { calloutName } from '../legacy/callout/Callout.types'; +import NativeCalloutView, { Commands } from '../specs/components/CalloutNativeComponent'; +import type { NativeProps } from '../specs/components/CalloutNativeComponent'; + +export interface CalloutHostProps extends CalloutProps { + ref?: React.Ref>; + nativeRef?: React.Ref>; + nativeTarget?: number; + commandGeneration?: number; + anchorMode?: NativeProps['anchorMode']; + onReady?: NativeProps['onReady']; + onClosed?: NativeProps['onClosed']; + onOperationResult?: NativeProps['onOperationResult']; +} + +const colorTransparent = '#00000000'; + +const styles = StyleSheet.create({ + root: { + position: 'absolute', + }, +}); + +/** + * Renders the native Callout without applying theme or appearance defaults. + */ +export const CalloutHost = phasedComponent((props) => { + const { componentRef, target } = props; + const nativeComponentRef = React.useRef | null>(null); + const [nativeTarget, setNativeTarget] = React.useState(undefined); + const BaseRoot = useSlot(NativeCalloutView, { ref: nativeComponentRef }); + const Root = useSlot(BaseRoot, props.nativeRef ? { ref: props.nativeRef } : {}); + + React.useImperativeHandle( + componentRef, + () => ({ + blurWindow() { + if (nativeComponentRef.current !== null) { + Commands.blurWindow(nativeComponentRef.current); + } + }, + focusWindow() { + if (nativeComponentRef.current !== null) { + Commands.focusWindow(nativeComponentRef.current); + } + }, + }), + [], + ); + + React.useLayoutEffect(() => { + if (typeof target === 'string') { + setNativeTarget(target); + } else if (target?.current) { + setNativeTarget(findNodeHandle(target.current) ?? undefined); + } else { + setNativeTarget(undefined); + } + }, [target]); + + return directComponent((renderProps) => { + const { + anchorRect, + backgroundColor, + beakWidth, + borderColor, + borderRadius, + borderWidth, + componentRef: _componentRef, + directionalHint, + dismissBehaviors, + gapSpace, + maxHeight, + maxWidth, + minPadding, + minWidth, + style, + target: _target, + nativeTarget: resolvedTarget, + nativeRef: _nativeRef, + ref, + ...nativeProps + } = mergeProps(props, renderProps); + + /** + * Build the style for the callout based on the props. For `borderColor`, `borderWidth`, `backgroundColor`, and + * `borderRadius` defaults values are required otherwise crashes in CalloutView.swift:updateLayer() will occur. + */ + const calloutStyle = { + backgroundColor: backgroundColor ?? colorTransparent, + borderColor: borderColor ?? colorTransparent, + borderWidth: borderWidth ?? 0, + borderRadius: borderRadius ?? 0, + ...(maxHeight != null && { maxHeight }), + ...(maxWidth != null && { maxWidth }), + ...(minWidth != null && { minWidth }), + }; + + const nativeStyle = [styles.root, calloutStyle, style]; + const element = ( + + ); + // Slot ref composition must not change the legacy style-array shape. + return React.cloneElement(element, { style: nativeStyle }); + }); +}); + +CalloutHost.displayName = calloutName; + +export default CalloutHost; diff --git a/packages/agentic/native-core/src/internal/FocusZoneHost.tsx b/packages/agentic/native-core/src/internal/FocusZoneHost.tsx new file mode 100644 index 00000000000..1b1d7704c7e --- /dev/null +++ b/packages/agentic/native-core/src/internal/FocusZoneHost.tsx @@ -0,0 +1,68 @@ +/** @jsxImportSource @fluentui-react-native/framework-base */ +import * as React from 'react'; +import { findNodeHandle } from 'react-native'; + +import { directComponent, mergeProps, phasedComponent, useSlot } from '@fluentui-react-native/framework-base'; +import { useViewCommandFocus } from '@fluentui-react-native/interactive-hooks'; + +import type { FocusZoneProps } from '../legacy/focus-zone/FocusZone.types'; +import { focusZoneName } from '../legacy/focus-zone/FocusZone.types'; +import NativeFocusZone from '../specs/components/FocusZoneNativeComponent'; +import type { NativeSyntheticEvent } from 'react-native'; +import type { NativeOperationResult } from '../components/nativeOperation'; + +export interface FocusZoneHostProps extends FocusZoneProps { + navigateAtEnd?: 'NavigateStopAtEnds' | 'NavigateWrap' | 'NavigateContinue'; + ref?: React.Ref>; + nativeRef?: React.Ref>; + nativeDefaultTarget?: number; + commandGeneration?: number; + onOperationResult?: (event: NativeSyntheticEvent) => void; +} + +/** + * Renders the native FocusZone without applying theme or appearance defaults. + */ +export const FocusZoneHost = phasedComponent((props) => { + const { componentRef, defaultTabbableElement } = props; + const nativeRef = useViewCommandFocus(componentRef); + const BaseRoot = useSlot(NativeFocusZone, { ref: nativeRef }); + const Root = useSlot(BaseRoot, props.nativeRef ? { ref: props.nativeRef } : {}); + const [nativeDefaultTabbableElement, setNativeDefaultTabbableElement] = React.useState(); + + React.useLayoutEffect(() => { + if (typeof defaultTabbableElement === 'string') { + setNativeDefaultTabbableElement(defaultTabbableElement); + } else if (defaultTabbableElement?.current) { + setNativeDefaultTabbableElement(findNodeHandle(defaultTabbableElement.current) ?? undefined); + } else { + setNativeDefaultTabbableElement(undefined); + } + }, [defaultTabbableElement]); + + return directComponent((renderProps) => { + const { + componentRef: _componentRef, + defaultTabbableElement: _defaultTabbableElement, + nativeDefaultTarget, + nativeRef: _nativeRef, + ref, + isCircularNavigation, + navigateAtEnd = isCircularNavigation ? 'NavigateWrap' : 'NavigateStopAtEnds', + ...nativeProps + } = mergeProps(props, renderProps) as FocusZoneHostProps & { + navigateAtEnd?: 'NavigateStopAtEnds' | 'NavigateWrap' | 'NavigateContinue'; + }; + + return ( + + ); + }); +}); + +FocusZoneHost.displayName = focusZoneName; diff --git a/packages/agentic/native-core/src/internal/focusZoneCommands.ts b/packages/agentic/native-core/src/internal/focusZoneCommands.ts new file mode 100644 index 00000000000..439b9b57f4a --- /dev/null +++ b/packages/agentic/native-core/src/internal/focusZoneCommands.ts @@ -0,0 +1,3 @@ +import { Commands } from '../specs/components/FocusZoneNativeComponent'; + +export const FocusCommands: typeof Commands = Commands; diff --git a/packages/agentic/native-core/src/internal/focusZoneCommands.win32.ts b/packages/agentic/native-core/src/internal/focusZoneCommands.win32.ts new file mode 100644 index 00000000000..a2340a78d90 --- /dev/null +++ b/packages/agentic/native-core/src/internal/focusZoneCommands.win32.ts @@ -0,0 +1,13 @@ +import type NativeFocusZone from '../specs/components/FocusZoneNativeComponent'; + +export const FocusCommands = { + requestFocus( + _view: React.ComponentRef, + _generation: number, + _requestId: number, + _target: number, + _strategy: string, + ): never { + throw new Error('Modern FocusZone commands are not registered by the Win32 host. Use the legacy native ref API.'); + }, +}; diff --git a/packages/agentic/native-core/src/internal/modernHost.ts b/packages/agentic/native-core/src/internal/modernHost.ts new file mode 100644 index 00000000000..8bd41eff90f --- /dev/null +++ b/packages/agentic/native-core/src/internal/modernHost.ts @@ -0,0 +1,11 @@ +import * as React from 'react'; +import { Platform } from 'react-native'; + +export function assertModernHost(component: string) { + if (Number.parseInt(React.version, 10) < 19) { + throw new Error(`Modern ${component} requires React 19. Legacy consumers must use native-core/legacy.`); + } + if (Platform.OS !== 'macos' && Platform.OS !== 'windows') { + throw new Error(`Modern ${component} requires the native-core macOS or Windows registration. Use /legacy on other hosts.`); + } +} diff --git a/packages/agentic/native-core/src/legacy/callout/Callout.tsx b/packages/agentic/native-core/src/legacy/callout/Callout.tsx index b9fdd5da92b..d2ef4d3f1f0 100644 --- a/packages/agentic/native-core/src/legacy/callout/Callout.tsx +++ b/packages/agentic/native-core/src/legacy/callout/Callout.tsx @@ -1,111 +1,13 @@ /** @jsxImportSource @fluentui-react-native/framework-base */ -import * as React from 'react'; -import { findNodeHandle, StyleSheet } from 'react-native'; - import { directComponent, mergeProps, phasedComponent } from '@fluentui-react-native/framework-base'; +import { CalloutHost } from '../../internal/CalloutHost'; import type { CalloutProps } from './Callout.types'; import { calloutName } from './Callout.types'; -import NativeCalloutView, { Commands } from '../../specs/components/CalloutNativeComponent'; - -const colorTransparent = '#00000000'; - -const styles = StyleSheet.create({ - root: { - position: 'absolute', - }, -}); - -/** - * Renders the native Callout without applying theme or appearance defaults. - */ -export const Callout = phasedComponent((props) => { - const { componentRef, target } = props; - const nativeComponentRef = React.useRef | null>(null); - const [nativeTarget, setNativeTarget] = React.useState(undefined); - - React.useImperativeHandle( - componentRef, - () => ({ - blurWindow() { - if (nativeComponentRef.current !== null) { - Commands.blurWindow(nativeComponentRef.current); - } - }, - focusWindow() { - if (nativeComponentRef.current !== null) { - Commands.focusWindow(nativeComponentRef.current); - } - }, - }), - [], - ); - React.useLayoutEffect(() => { - if (typeof target === 'string') { - setNativeTarget(target); - } else if (target?.current) { - setNativeTarget(findNodeHandle(target.current) ?? undefined); - } else { - setNativeTarget(undefined); - } - }, [target]); - - return directComponent((renderProps) => { - const { - anchorRect, - backgroundColor, - beakWidth, - borderColor, - borderRadius, - borderWidth, - componentRef: _componentRef, - directionalHint, - dismissBehaviors, - gapSpace, - maxHeight, - maxWidth, - minPadding, - minWidth, - style, - target: _target, - ...nativeProps - } = mergeProps(props, renderProps); - - /** - * Build the style for the callout based on the props. For `borderColor`, `borderWidth`, `backgroundColor`, and - * `borderRadius` defaults values are required otherwise crashes in CalloutView.swift:updateLayer() will occur. - */ - const calloutStyle = { - backgroundColor: backgroundColor ?? colorTransparent, - borderColor: borderColor ?? colorTransparent, - borderWidth: borderWidth ?? 0, - borderRadius: borderRadius ?? 0, - ...(maxHeight != null && { maxHeight }), - ...(maxWidth != null && { maxWidth }), - ...(minWidth != null && { minWidth }), - }; - - return ( - - ); - }); -}); +export const Callout = phasedComponent((baseProps) => + directComponent((props) => ), +); Callout.displayName = calloutName; - export default Callout; diff --git a/packages/agentic/native-core/src/legacy/focus-zone/FocusZone.tsx b/packages/agentic/native-core/src/legacy/focus-zone/FocusZone.tsx index 0430f6c1123..32ecc49f40e 100644 --- a/packages/agentic/native-core/src/legacy/focus-zone/FocusZone.tsx +++ b/packages/agentic/native-core/src/legacy/focus-zone/FocusZone.tsx @@ -1,50 +1,12 @@ /** @jsxImportSource @fluentui-react-native/framework-base */ -import * as React from 'react'; -import { findNodeHandle } from 'react-native'; - import { directComponent, mergeProps, phasedComponent } from '@fluentui-react-native/framework-base'; -import { useViewCommandFocus } from '@fluentui-react-native/interactive-hooks'; +import { FocusZoneHost } from '../../internal/FocusZoneHost'; import type { FocusZoneProps } from './FocusZone.types'; import { focusZoneName } from './FocusZone.types'; -import NativeFocusZone from '../../specs/components/FocusZoneNativeComponent'; - -/** - * Renders the native FocusZone without applying theme or appearance defaults. - */ -export const FocusZone = phasedComponent((props) => { - const { componentRef, defaultTabbableElement } = props; - const nativeRef = useViewCommandFocus(componentRef); - const [nativeDefaultTabbableElement, setNativeDefaultTabbableElement] = React.useState(); - - React.useLayoutEffect(() => { - if (typeof defaultTabbableElement === 'string') { - setNativeDefaultTabbableElement(defaultTabbableElement); - } else if (defaultTabbableElement?.current) { - setNativeDefaultTabbableElement(findNodeHandle(defaultTabbableElement.current) ?? undefined); - } else { - setNativeDefaultTabbableElement(undefined); - } - }, [defaultTabbableElement]); - - return directComponent((renderProps) => { - const { - componentRef: _componentRef, - defaultTabbableElement: _defaultTabbableElement, - isCircularNavigation, - navigateAtEnd = isCircularNavigation ? 'NavigateWrap' : 'NavigateStopAtEnds', - ...nativeProps - } = mergeProps(props, renderProps) as FocusZoneProps & { navigateAtEnd?: 'NavigateStopAtEnds' | 'NavigateWrap' | 'NavigateContinue' }; - return ( - - ); - }); -}); +export const FocusZone = phasedComponent((baseProps) => + directComponent((props) => ), +); FocusZone.displayName = focusZoneName; diff --git a/packages/agentic/native-core/src/macos/index.ts b/packages/agentic/native-core/src/macos/index.ts index cb0ff5c3b54..259de129fd6 100644 --- a/packages/agentic/native-core/src/macos/index.ts +++ b/packages/agentic/native-core/src/macos/index.ts @@ -1 +1,15 @@ -export {}; +export { Callout } from '../components/callout/Callout'; +export type { + CalloutProps, + CalloutHandle, + CalloutAnchor, + CalloutPresentation, + CalloutReadyEvent, + CalloutDismissedEvent, + CalloutDismissReason, +} from '../components/callout/Callout.types'; +export { FocusZone } from '../components/focus-zone/FocusZone'; +export type { FocusZoneProps, FocusZoneHandle, FocusZoneCommands } from '../components/focus-zone/FocusZone.types'; +export { useNativeViewTarget } from '../components/nativeTarget'; +export type { NativeViewTarget } from '../components/nativeTarget'; +export type { NativeOperationOptions, NativeOperationOutcome, NativeOperationStatus } from '../components/nativeOperation'; diff --git a/packages/agentic/native-core/src/specs/components/CalloutNativeComponent.ts b/packages/agentic/native-core/src/specs/components/CalloutNativeComponent.ts index 7d5bb83e993..f8888b48a95 100644 --- a/packages/agentic/native-core/src/specs/components/CalloutNativeComponent.ts +++ b/packages/agentic/native-core/src/specs/components/CalloutNativeComponent.ts @@ -15,6 +15,11 @@ interface AnchorRect { * Shared native props specific to Callout native component */ export interface NativeProps extends ViewProps { + commandGeneration?: Int32; + anchorMode?: WithDefault<'legacy' | 'view' | 'rect' | 'point', 'legacy'>; + onReady?: DirectEventHandler<{ generation: Int32 }>; + onClosed?: DirectEventHandler<{ generation: Int32; reason: string }>; + onOperationResult?: DirectEventHandler<{ generation: Int32; requestId: Int32; status: string }>; accessibilityLabel?: string; accessibilityOnShowAnnouncement?: string; anchorRect?: AnchorRect; @@ -63,10 +68,13 @@ export type CalloutComponentType = HostComponent; interface NativeCalloutCommands { focusWindow: (viewRef: React.ElementRef) => void; blurWindow: (viewRef: React.ElementRef) => void; + requestFocus: (viewRef: React.ElementRef, generation: Int32, requestId: Int32, targetTag: Int32) => void; + close: (viewRef: React.ElementRef, generation: Int32, requestId: Int32) => void; + reposition: (viewRef: React.ElementRef, generation: Int32, requestId: Int32) => void; } export const Commands: NativeCalloutCommands = codegenNativeCommands({ - supportedCommands: ['blurWindow', 'focusWindow'], + supportedCommands: ['blurWindow', 'focusWindow', 'requestFocus', 'close', 'reposition'], }); export default codegenNativeComponent('Callout', { diff --git a/packages/agentic/native-core/src/specs/components/FocusZoneNativeComponent.ts b/packages/agentic/native-core/src/specs/components/FocusZoneNativeComponent.ts index daa1e39599c..cd0b5558e86 100644 --- a/packages/agentic/native-core/src/specs/components/FocusZoneNativeComponent.ts +++ b/packages/agentic/native-core/src/specs/components/FocusZoneNativeComponent.ts @@ -4,11 +4,14 @@ * @format */ import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent'; +import codegenNativeCommands from 'react-native/Libraries/Utilities/codegenNativeCommands'; import type { HostComponent, ViewProps } from 'react-native'; -import type { UnsafeMixed, WithDefault } from 'react-native/Libraries/Types/CodegenTypes'; +import type { UnsafeMixed, WithDefault, Int32, DirectEventHandler } from 'react-native/Libraries/Types/CodegenTypes'; export interface NativeProps extends ViewProps { + commandGeneration?: Int32; + onOperationResult?: DirectEventHandler<{ generation: Int32; requestId: Int32; status: string }>; navigateAtEnd?: WithDefault<'NavigateStopAtEnds' | 'NavigateWrap' | 'NavigateContinue', 'NavigateStopAtEnds'>; defaultTabbableElement?: UnsafeMixed; focusZoneDirection?: WithDefault<'bidirectional' | 'vertical' | 'horizontal' | 'none', 'bidirectional'>; @@ -21,6 +24,18 @@ export interface NativeProps extends ViewProps { export type FocusZoneComponentType = HostComponent; +interface NativeCommands { + requestFocus: ( + viewRef: React.ElementRef, + generation: Int32, + requestId: Int32, + targetTag: Int32, + strategy: string, + ) => void; +} + +export const Commands: NativeCommands = codegenNativeCommands({ supportedCommands: ['requestFocus'] }); + export default codegenNativeComponent('FocusZone', { paperComponentName: 'RCTFocusZone', }) as FocusZoneComponentType; diff --git a/packages/agentic/native-core/src/windows/index.ts b/packages/agentic/native-core/src/windows/index.ts index cb0ff5c3b54..259de129fd6 100644 --- a/packages/agentic/native-core/src/windows/index.ts +++ b/packages/agentic/native-core/src/windows/index.ts @@ -1 +1,15 @@ -export {}; +export { Callout } from '../components/callout/Callout'; +export type { + CalloutProps, + CalloutHandle, + CalloutAnchor, + CalloutPresentation, + CalloutReadyEvent, + CalloutDismissedEvent, + CalloutDismissReason, +} from '../components/callout/Callout.types'; +export { FocusZone } from '../components/focus-zone/FocusZone'; +export type { FocusZoneProps, FocusZoneHandle, FocusZoneCommands } from '../components/focus-zone/FocusZone.types'; +export { useNativeViewTarget } from '../components/nativeTarget'; +export type { NativeViewTarget } from '../components/nativeTarget'; +export type { NativeOperationOptions, NativeOperationOutcome, NativeOperationStatus } from '../components/nativeOperation'; diff --git a/packages/agentic/native-core/tsconfig.stories.json b/packages/agentic/native-core/tsconfig.stories.json index 40b8e99540d..43adfe8cf77 100644 --- a/packages/agentic/native-core/tsconfig.stories.json +++ b/packages/agentic/native-core/tsconfig.stories.json @@ -6,7 +6,7 @@ "noEmit": true, "tsBuildInfoFile": ".cache/tsconfig.stories.tsbuildinfo" }, - "include": ["src/legacy/callout/Callout.stories.tsx"], + "include": ["src/legacy/callout/Callout.stories.tsx", "src/components/**/*.stories.tsx"], "exclude": [], "references": [ { diff --git a/packages/agentic/native-core/windows/NativeCore/NativeCore.vcxproj b/packages/agentic/native-core/windows/NativeCore/NativeCore.vcxproj index ac701265e98..ca20aaf62ff 100644 --- a/packages/agentic/native-core/windows/NativeCore/NativeCore.vcxproj +++ b/packages/agentic/native-core/windows/NativeCore/NativeCore.vcxproj @@ -109,6 +109,7 @@ + ReactPackageProvider.idl diff --git a/packages/agentic/native-core/windows/NativeCore/codegen/react/components/FRNNativeCoreSpec/Callout.g.h b/packages/agentic/native-core/windows/NativeCore/codegen/react/components/FRNNativeCoreSpec/Callout.g.h index 072434e2c08..4f3847b9292 100644 --- a/packages/agentic/native-core/windows/NativeCore/codegen/react/components/FRNNativeCoreSpec/Callout.g.h +++ b/packages/agentic/native-core/windows/NativeCore/codegen/react/components/FRNNativeCoreSpec/Callout.g.h @@ -40,6 +40,8 @@ struct CalloutProps : winrt::implements(); + commandGeneration = cloneFromProps->commandGeneration; + anchorMode = cloneFromProps->anchorMode; accessibilityLabel = cloneFromProps->accessibilityLabel; accessibilityOnShowAnnouncement = cloneFromProps->accessibilityOnShowAnnouncement; anchorRect = cloneFromProps->anchorRect; @@ -57,6 +59,9 @@ struct CalloutProps : winrt::implementstestID; directionalHint = cloneFromProps->directionalHint; target = cloneFromProps->target.Copy(); + onReady = cloneFromProps->onReady; + onClosed = cloneFromProps->onClosed; + onOperationResult = cloneFromProps->onOperationResult; onRestoreFocus = cloneFromProps->onRestoreFocus; onDismiss = cloneFromProps->onDismiss; onShow = cloneFromProps->onShow; @@ -67,6 +72,12 @@ struct CalloutProps : winrt::implements commandGeneration{}; + + REACT_FIELD(anchorMode) + std::optional anchorMode; + REACT_FIELD(accessibilityLabel) std::optional accessibilityLabel; @@ -119,6 +130,15 @@ struct CalloutProps : winrt::implements(); auto commandName = args.CommandName(); @@ -259,6 +336,31 @@ struct BaseCallout { userData->HandleBlurWindowCommand(); return; } + + if (commandName == L"requestFocus") { + int32_t generation; +int32_t requestId; +int32_t targetTag; + winrt::Microsoft::ReactNative::ReadArgs(args.CommandArgs(), generation, requestId, targetTag); + userData->HandleRequestFocusCommand(generation, requestId, targetTag); + return; + } + + if (commandName == L"close") { + int32_t generation; +int32_t requestId; + winrt::Microsoft::ReactNative::ReadArgs(args.CommandArgs(), generation, requestId); + userData->HandleCloseCommand(generation, requestId); + return; + } + + if (commandName == L"reposition") { + int32_t generation; +int32_t requestId; + winrt::Microsoft::ReactNative::ReadArgs(args.CommandArgs(), generation, requestId); + userData->HandleRepositionCommand(generation, requestId); + return; + } } const std::shared_ptr& EventEmitter() const { return m_eventEmitter; } diff --git a/packages/agentic/native-core/windows/NativeCore/codegen/react/components/FRNNativeCoreSpec/FocusZone.g.h b/packages/agentic/native-core/windows/NativeCore/codegen/react/components/FRNNativeCoreSpec/FocusZone.g.h index e6e20806144..669ae50e0cb 100644 --- a/packages/agentic/native-core/windows/NativeCore/codegen/react/components/FRNNativeCoreSpec/FocusZone.g.h +++ b/packages/agentic/native-core/windows/NativeCore/codegen/react/components/FRNNativeCoreSpec/FocusZone.g.h @@ -25,6 +25,7 @@ struct FocusZoneProps : winrt::implements(); + commandGeneration = cloneFromProps->commandGeneration; navigateAtEnd = cloneFromProps->navigateAtEnd; defaultTabbableElement = cloneFromProps->defaultTabbableElement.Copy(); focusZoneDirection = cloneFromProps->focusZoneDirection; @@ -33,7 +34,7 @@ struct FocusZoneProps : winrt::implementsdisabled; isTabNavigation = cloneFromProps->isTabNavigation; navigationOrderInRenderOrder = cloneFromProps->navigationOrderInRenderOrder; - + onOperationResult = cloneFromProps->onOperationResult; } } @@ -41,6 +42,9 @@ struct FocusZoneProps : winrt::implements commandGeneration{}; + REACT_FIELD(navigateAtEnd) std::optional navigateAtEnd; @@ -65,13 +69,37 @@ struct FocusZoneProps : winrt::implements navigationOrderInRenderOrder{}; + // These fields can be used to determine if JS has registered for this event + REACT_FIELD(onOperationResult) + bool onOperationResult{false}; + const winrt::Microsoft::ReactNative::ViewProps ViewProps; }; +REACT_STRUCT(FocusZoneSpec_onOperationResult) +struct FocusZoneSpec_onOperationResult { + REACT_FIELD(generation) + int32_t generation{}; + + REACT_FIELD(requestId) + int32_t requestId{}; + + REACT_FIELD(status) + std::string status; +}; + struct FocusZoneEventEmitter { FocusZoneEventEmitter(const winrt::Microsoft::ReactNative::EventEmitter &eventEmitter) : m_eventEmitter(eventEmitter) {} + using OnOperationResult = FocusZoneSpec_onOperationResult; + + void onOperationResult(OnOperationResult &&value) const { + m_eventEmitter.DispatchEvent(L"operationResult", [value = std::move(value)](const winrt::Microsoft::ReactNative::IJSValueWriter writer) { + winrt::Microsoft::ReactNative::WriteValue(writer, value); + }); + } + private: winrt::Microsoft::ReactNative::EventEmitter m_eventEmitter{nullptr}; }; @@ -133,7 +161,22 @@ struct BaseFocusZone { return nullptr; } - + // You must provide an implementation of this method to handle the "requestFocus" command + virtual void HandleRequestFocusCommand(int32_t generation, int32_t requestId, int32_t targetTag, std::string strategy) noexcept = 0; + + void HandleCommand(const winrt::Microsoft::ReactNative::ComponentView &view, const winrt::Microsoft::ReactNative::HandleCommandArgs& args) noexcept { + auto userData = view.UserData().as(); + auto commandName = args.CommandName(); + if (commandName == L"requestFocus") { + int32_t generation; +int32_t requestId; +int32_t targetTag; +std::string strategy; + winrt::Microsoft::ReactNative::ReadArgs(args.CommandArgs(), generation, requestId, targetTag, strategy); + userData->HandleRequestFocusCommand(generation, requestId, targetTag, strategy); + return; + } + } const std::shared_ptr& EventEmitter() const { return m_eventEmitter; } const winrt::com_ptr& Props() const { return m_props; } @@ -192,6 +235,12 @@ void RegisterFocusZoneNativeComponent( }); } + builder.SetCustomCommandHandler([](const winrt::Microsoft::ReactNative::ComponentView &view, + const winrt::Microsoft::ReactNative::HandleCommandArgs& args) noexcept { + auto userData = view.UserData().as(); + userData->HandleCommand(view, args); + }); + if CONSTEXPR_SUPPORTED_ON_VIRTUAL_FN_ADDRESS (&TUserData::MountChildComponentView != &BaseFocusZone::MountChildComponentView) { builder.SetMountChildComponentViewHandler([](const winrt::Microsoft::ReactNative::ComponentView &view, const winrt::Microsoft::ReactNative::MountChildComponentViewArgs &args) noexcept { diff --git a/packages/agentic/native-core/windows/NativeCore/components/Callout/Callout.cpp b/packages/agentic/native-core/windows/NativeCore/components/Callout/Callout.cpp index 5ac6311e78c..8d8b07c0fec 100644 --- a/packages/agentic/native-core/windows/NativeCore/components/Callout/Callout.cpp +++ b/packages/agentic/native-core/windows/NativeCore/components/Callout/Callout.cpp @@ -3,6 +3,7 @@ #include "pch.h" #include "Callout.h" +#include "../../shared/FocusRequest.h" #include #include @@ -68,6 +69,7 @@ struct CalloutComponentView winrt::Windows::Foundation::IInspectable>, Codegen::BaseCallout { ~CalloutComponentView() { + if (m_lightDismiss && m_lightDismissToken.value) m_lightDismiss.Dismissed(m_lightDismissToken); if (m_popup && !m_popup.IsClosed()) { /* // Unregister closing event handler @@ -98,6 +100,11 @@ struct CalloutComponentView const winrt::com_ptr &newProps, const winrt::com_ptr &oldProps) noexcept override { + const auto generation = newProps->commandGeneration.value_or(0); + if (!oldProps || generation != oldProps->commandGeneration.value_or(0)) { + m_closed = false; + m_ready = false; + } if (!oldProps || newProps->directionalHint != oldProps->directionalHint) { m_directionalHint = newProps->directionalHint @@ -113,12 +120,16 @@ struct CalloutComponentView } __super::UpdateProps(view, newProps, oldProps); + if (auto portal = m_portal.get(); portal && portal.ContentRoot().Children().Size()) { + AdjustWindowSize(portal.ContentRoot().Children().GetAt(0).LayoutMetrics()); + } } void InitializePortalViewComponent( const winrt::Microsoft::ReactNative::Composition::PortalComponentView &portalComponentView) noexcept { m_reactContext = portalComponentView.ReactContext(); + m_portal = portalComponentView; portalComponentView.Mounted([](const auto & /*sender*/, const auto &view) { view.UserData().as()->OnMounted(view); @@ -139,6 +150,39 @@ struct CalloutComponentView // nyi } + void HandleRequestFocusCommand(int32_t generation, int32_t requestId, int32_t targetTag) noexcept override { + auto status = OperationStatus(generation); + if (status.empty()) { + auto portal = m_portal.get(); + auto root = portal.ContentRoot(); + auto target = ::FRNNativeCore::FindDescendant(root, targetTag); + if (!target) status = "not-mounted"; + else if (!::FRNNativeCore::Eligible(target)) status = "not-focusable"; + else { + auto foreground = ::GetForegroundWindow(); + auto popupHwnd = winrt::Microsoft::UI::GetWindowFromWindowId(m_rnWindow.AppWindow().Id()); + if (foreground != ::GetAncestor(m_parentHwnd, GA_ROOT) && foreground != popupHwnd) status = "inactive-window"; + else if (!winrt::Microsoft::UI::Input::InputFocusController::GetForIsland(m_rnWindow.ReactNativeIsland().Island()).TrySetFocus()) status = "refused"; + else status = ::FRNNativeCore::FocusWithin(root, targetTag, "target", 0, m_parentHwnd); + } + } + EmitResult(generation, requestId, status); + } + void HandleCloseCommand(int32_t generation, int32_t requestId) noexcept override { + auto status = OperationStatus(generation); + if (!status.empty()) { EmitResult(generation, requestId, status); return; } + ClosePresentation("programmatic", requestId); + } + void HandleRepositionCommand(int32_t generation, int32_t requestId) noexcept override { + auto status = OperationStatus(generation); + if (status.empty()) { + auto portal = m_portal.get(); + if (portal.ContentRoot().Children().Size()) AdjustWindowSize(portal.ContentRoot().Children().GetAt(0).LayoutMetrics()); + status = m_positioned ? "confirmed" : "not-ready"; + } + EmitResult(generation, requestId, status); + } + void MountChildComponentView( const winrt::Microsoft::ReactNative::ComponentView & /*view*/, const winrt::Microsoft::ReactNative::MountChildComponentViewArgs @@ -173,21 +217,43 @@ struct CalloutComponentView } private: + bool Modern() const noexcept { return Props() && Props()->commandGeneration.value_or(0) > 0; } + std::string OperationStatus(int32_t generation) const noexcept { + if (!Props() || generation <= 0 || Props()->commandGeneration.value_or(0) != generation) return "stale"; + if (!m_mounted || !m_ready || m_closed) return "not-ready"; + return {}; + } + void EmitResult(int32_t generation, int32_t requestId, const std::string &status) noexcept { + if (auto emitter = EventEmitter()) emitter->onOperationResult({generation, requestId, status}); + } + void ClosePresentation(const std::string &reason, int32_t requestId = 0) noexcept { + if (m_closed) return; + m_closed = true; + m_ready = false; + if (m_popup && m_popup.IsVisible()) m_popup.Hide(); + if (requestId) EmitResult(Props()->commandGeneration.value_or(0), requestId, "confirmed"); + if (auto emitter = EventEmitter()) { + if (Modern()) emitter->onClosed({Props()->commandGeneration.value_or(0), reason}); + emitter->onDismiss({}); + } + } + void OnMounted(const winrt::Microsoft::ReactNative::ComponentView &view) noexcept { assert(!m_mounted); m_mounted = true; + const auto lifetime = ++m_lifetime; CreatePopup(view); m_showQueued = true; m_reactContext.UIDispatcher().Post( - [wkThis = get_weak(), wkView = winrt::weak_ref(view)]() { + [wkThis = get_weak(), wkView = winrt::weak_ref(view), lifetime]() { if (auto strongThis = wkThis.get()) { strongThis->m_showQueued = false; - if (!strongThis->m_mounted) { + if (!strongThis->m_mounted || strongThis->m_lifetime != lifetime) { return; } if (auto v = wkView.get()) { @@ -200,7 +266,18 @@ struct CalloutComponentView void OnUnmounted( const winrt::Microsoft::ReactNative::ComponentView & /*view*/) noexcept { assert(m_mounted); + if (Modern()) ClosePresentation("host-detached"); m_mounted = false; + ++m_lifetime; + if (m_lightDismiss && m_lightDismissToken.value) m_lightDismiss.Dismissed(m_lightDismissToken); + m_lightDismissToken = {}; + m_lightDismiss = nullptr; + if (Modern()) { + if (m_rnWindow) m_rnWindow.Close(); + if (m_popup && !m_popup.IsClosed()) m_popup.Close(); + m_rnWindow = nullptr; + m_popup = nullptr; + } } void OnLightDismissDismissed( @@ -210,6 +287,7 @@ struct CalloutComponentView } void HidePopup() { + if (Modern()) { ClosePresentation("native-light-dismiss"); return; } if (!m_popup) return; m_popup.Hide(); @@ -224,6 +302,7 @@ struct CalloutComponentView auto portal = view.as< winrt::Microsoft::ReactNative::Composition::PortalComponentView>(); + m_parentHwnd = view.as<::Microsoft::ReactNative::Composition::Experimental::IComponentViewInterop>()->GetHwndForParenting(); m_popup = winrt::Microsoft::UI::Content::DesktopPopupSiteBridge::Create( portal.Parent() .as() @@ -240,10 +319,10 @@ struct CalloutComponentView m_rnWindow.ResizePolicy( winrt::Microsoft::ReactNative::ContentSizePolicy::None); - auto inputLightDismissAction = + m_lightDismiss = winrt::Microsoft::UI::Input::InputLightDismissAction::GetForWindowId( m_rnWindow.AppWindow().Id()); - inputLightDismissAction.Dismissed( + m_lightDismissToken = m_lightDismiss.Dismissed( {get_weak(), &CalloutComponentView::OnLightDismissDismissed}); if (portal.ContentRoot().Children().Size()) { @@ -255,23 +334,31 @@ struct CalloutComponentView void RegisterLightDismissAction() noexcept {} void Show() noexcept { + if (Modern() && (m_closed || !m_positioned)) return; m_popup.Show(); - winrt::Microsoft::UI::Input::InputFocusController::GetForIsland( + if (!Modern()) { + winrt::Microsoft::UI::Input::InputFocusController::GetForIsland( m_rnWindow.ReactNativeIsland().Island()) .TrySetFocus(); - m_rnWindow.ReactNativeIsland().NavigateFocus( + m_rnWindow.ReactNativeIsland().NavigateFocus( winrt::Microsoft::ReactNative::FocusNavigationRequest:: FocusNavigationRequest( winrt::Microsoft::ReactNative::FocusNavigationReason::First)); + } if (auto eventEmitter = EventEmitter()) { eventEmitter->onShow({}); + if (Modern() && !m_ready) { + m_ready = true; + eventEmitter->onReady({Props()->commandGeneration.value_or(0)}); + } } } void AdjustWindowSize(const winrt::Microsoft::ReactNative::LayoutMetrics &layoutMetrics) noexcept { + m_positioned = false; if (!m_rnWindow) { return; } @@ -296,6 +383,10 @@ struct CalloutComponentView auto targetView = winrt::Microsoft::ReactNative::Composition:: CompositionUIService::ComponentFromReactTag( m_reactContext.Handle(), Props()->target.AsInt64()); + if (!targetView) { + if (Modern() && m_ready) ClosePresentation("anchor-lost"); + return; + } auto targetPos = ViewToScreenOffset(targetView); auto targetScaleFactor = targetView.LayoutMetrics().PointScaleFactor; @@ -303,6 +394,15 @@ struct CalloutComponentView targetView.LayoutMetrics().Frame.Width * targetScaleFactor); auto targetHeightPx = static_cast( targetView.LayoutMetrics().Frame.Height * targetScaleFactor); + const auto anchorMode = Props()->anchorMode.value_or("legacy"); + if (anchorMode == "rect" || anchorMode == "point") { + if (!Props()->anchorRect) { ClosePresentation("anchor-lost"); return; } + const auto &rect = Props()->anchorRect.value(); + targetPos.X += static_cast(rect.screenX * targetScaleFactor); + targetPos.Y += static_cast(rect.screenY * targetScaleFactor); + targetWidthPx = static_cast(rect.width * targetScaleFactor); + targetHeightPx = static_cast(rect.height * targetScaleFactor); + } auto targetRight = targetPos.X + targetWidthPx; auto targetBottom = targetPos.Y + targetHeightPx; auto targetCenterX = targetPos.X + targetWidthPx / 2; @@ -360,15 +460,21 @@ struct CalloutComponentView } flags |= TPM_WORKAREA; + const auto gap = static_cast(Props()->gapSpace.value_or(0) * targetScaleFactor); + if (m_directionalHint == DirectionalHint::LeftTopEdge || m_directionalHint == DirectionalHint::LeftCenter || m_directionalHint == DirectionalHint::LeftBottomEdge) anchorPoint.x -= gap; + else if (m_directionalHint == DirectionalHint::RightTopEdge || m_directionalHint == DirectionalHint::RightCenter || m_directionalHint == DirectionalHint::RightBottomEdge) anchorPoint.x += gap; + else if (m_directionalHint == DirectionalHint::TopLeftEdge || m_directionalHint == DirectionalHint::TopAutoEdge || m_directionalHint == DirectionalHint::TopCenter || m_directionalHint == DirectionalHint::TopRightEdge) anchorPoint.y -= gap; + else anchorPoint.y += gap; RECT finalPos; - CalculatePopupWindowPosition(&anchorPoint, &windowSize, flags, - &excludeRect, &finalPos); + if (!CalculatePopupWindowPosition(&anchorPoint, &windowSize, flags, &excludeRect, &finalPos)) return; m_rnWindow.AppWindow().Move({finalPos.left, finalPos.top}); + m_positioned = true; + if (Modern() && m_mounted && !m_showQueued && !m_closed && !m_popup.IsVisible()) Show(); } else { - // TODO target named anchors + if (Modern()) return; m_rnWindow.AppWindow().Move({0, 0}); } }; @@ -394,6 +500,14 @@ struct CalloutComponentView DirectionalHint m_directionalHint{DirectionalHint::LeftTopEdge}; bool m_showQueued{false}; bool m_mounted{false}; + bool m_positioned{false}; + bool m_ready{false}; + bool m_closed{false}; + uint64_t m_lifetime{0}; + HWND m_parentHwnd{nullptr}; + winrt::weak_ref m_portal; + winrt::Microsoft::UI::Input::InputLightDismissAction m_lightDismiss{nullptr}; + winrt::event_token m_lightDismissToken; winrt::event_token m_childLayoutMetricsToken; winrt::Microsoft::UI::Content::DesktopPopupSiteBridge m_popup{nullptr}; winrt::Microsoft::ReactNative::ReactNativeWindow m_rnWindow{nullptr}; diff --git a/packages/agentic/native-core/windows/NativeCore/components/FocusZone/FocusZoneComponentView.cpp b/packages/agentic/native-core/windows/NativeCore/components/FocusZone/FocusZoneComponentView.cpp index b373546a62a..36879f21ebc 100644 --- a/packages/agentic/native-core/windows/NativeCore/components/FocusZone/FocusZoneComponentView.cpp +++ b/packages/agentic/native-core/windows/NativeCore/components/FocusZone/FocusZoneComponentView.cpp @@ -5,6 +5,7 @@ #ifdef RNW_NEW_ARCH #include "codegen/react/components/FRNNativeCoreSpec/FocusZone.g.h" +#include "../../shared/FocusRequest.h" namespace winrt::FRNFocusZone { @@ -31,6 +32,7 @@ struct FocusZoneComponentView : winrt::implements, FRNNativeCoreCodegen::BaseFocusZone { void Initialize(const winrtRN::ComponentView &view) noexcept override { + m_view = view; m_containerTag = view.Tag(); view.KeyDown( @@ -67,6 +69,22 @@ struct FocusZoneComponentView }); } + void HandleRequestFocusCommand(int32_t generation, int32_t requestId, int32_t targetTag, std::string strategy) noexcept override { + std::string status; + auto view = m_view.get(); + auto props = Props(); + if (!view) status = "not-mounted"; + else if (!props || generation <= 0 || props->commandGeneration.value_or(0) != generation) status = "stale"; + else if (Disabled()) status = "not-focusable"; + else { + const bool previousTrap = m_inTabTrap; + m_inTabTrap = true; + status = ::FRNNativeCore::FocusWithin(view, targetTag, strategy, DefaultTabbableTag()); + m_inTabTrap = previousTrap; + } + if (auto emitter = EventEmitter()) emitter->onOperationResult({generation, requestId, status}); + } + private: void HandleKeyDown(const winrtRN::ComponentView &view, const winrtInput::KeyRoutedEventArgs &args) noexcept { using winrt::Windows::System::VirtualKey; @@ -577,6 +595,7 @@ struct FocusZoneComponentView } int64_t m_containerTag{0}; + winrt::weak_ref m_view; int64_t m_lastFocusedTag{0}; bool m_inTabTrap{false}; }; diff --git a/packages/agentic/native-core/windows/NativeCore/shared/FocusRequest.h b/packages/agentic/native-core/windows/NativeCore/shared/FocusRequest.h new file mode 100644 index 00000000000..939741602a1 --- /dev/null +++ b/packages/agentic/native-core/windows/NativeCore/shared/FocusRequest.h @@ -0,0 +1,52 @@ +#pragma once + +#include + +namespace FRNNativeCore { +namespace rn = winrt::Microsoft::ReactNative; +namespace comp = winrt::Microsoft::ReactNative::Composition; + +inline rn::ComponentView FindDescendant(const rn::ComponentView &root, int64_t tag) noexcept { + for (const auto &child : root.Children()) { + if (child.Tag() == tag) return child; + if (auto found = FindDescendant(child, tag)) return found; + } + return nullptr; +} + +inline bool Eligible(const rn::ComponentView &view) noexcept { + if (!view) return false; + auto eligible = comp::FocusManager::FindFirstFocusableElement(view); + return eligible && eligible.Tag() == view.Tag(); +} + +inline std::string FocusWithin( + const rn::ComponentView &container, int64_t tag, + const std::string &strategy, int64_t defaultTag = 0, HWND allowedOwner = nullptr) noexcept { + if (!container) return "not-mounted"; + auto root = container.as().Root(); + if (!root) return "not-ready"; + auto hwnd = container.as<::Microsoft::ReactNative::Composition::Experimental::IComponentViewInterop>()->GetHwndForParenting(); + auto foreground = ::GetForegroundWindow(); + if (!foreground || (::GetAncestor(hwnd, GA_ROOT) != foreground && + (!allowedOwner || ::GetAncestor(allowedOwner, GA_ROOT) != foreground))) return "inactive-window"; + + rn::ComponentView target{nullptr}; + if (strategy == "target") target = FindDescendant(container, tag); + else if (strategy == "default" && defaultTag) { + target = FindDescendant(container, defaultTag); + if (!target) return "not-mounted"; + } + else if (strategy != "default" && strategy != "first" && strategy != "last") return "unsupported"; + if (!target && strategy != "target") { + target = strategy == "last" ? comp::FocusManager::FindLastFocusableElement(container) + : comp::FocusManager::FindFirstFocusableElement(container); + if (target && target.Tag() == container.Tag()) target = nullptr; + } + if (!target) return "not-mounted"; + if (!Eligible(target)) return "not-focusable"; + if (!target.TryFocus(rn::FocusState::Programmatic)) return "refused"; + auto focused = root.GetFocusedComponent(); + return focused && focused.Tag() == target.Tag() ? "confirmed" : "refused"; +} +} From d7422eff577e4ddf459e908ec6c61e3e151e48ae Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Fri, 2 Oct 2026 19:34:36 -0700 Subject: [PATCH 02/15] Qualify Windows native focus commands and legacy desktop regressions Prevent entry preferences from redirecting explicit Windows FocusZone commands. Add live-target refusal and detach/cancellation stories, deduplicate compatible Storybook runtime dependencies, and record the remaining modernization gates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/native-core-modern-preview.md | 4 + packages/agentic/native-core/README.md | 6 +- .../native-core/plans/modernization.md | 76 +++++++++++- .../focus-zone/FocusZone.stories.tsx | 110 ++++++++++++++++++ .../src/components/focus-zone/SPEC.md | 3 +- .../FocusZone/FocusZoneComponentView.cpp | 2 +- 6 files changed, 192 insertions(+), 9 deletions(-) diff --git a/.changeset/native-core-modern-preview.md b/.changeset/native-core-modern-preview.md index 1c3988d44b3..96b637e9890 100644 --- a/.changeset/native-core-modern-preview.md +++ b/.changeset/native-core-modern-preview.md @@ -6,3 +6,7 @@ Add platform-scoped React 19 Callout and FocusZone previews with live targets, confirmed cancellable native commands, shared legacy host adapters, Swift macOS focus/placement services, and a cross-machine validation plan. Preserve Win32 legacy registrations without new managed transport. + +Honor explicit Windows FocusZone destinations without entry-focus redirection +and cover live-target refusal, cancellation, and detached command handles in +native stories. diff --git a/packages/agentic/native-core/README.md b/packages/agentic/native-core/README.md index 7133d33c848..c9ebaa9b14f 100644 --- a/packages/agentic/native-core/README.md +++ b/packages/agentic/native-core/README.md @@ -281,7 +281,11 @@ registrations. macOS shares Swift focus eligibility/actual-responder and placement behavior between the renderer adapters; the existing navigation algorithm is not rewritten merely to change language. -Windows source/codegen is wired but requires native build/runtime qualification. +Windows source/codegen and the native library build are qualified on RNW 0.81.35. +Initial modern FocusZone command/target-lifetime and legacy navigation cases +pass; Windows Callout runtime and the remaining protocol/geometry cases are +still unqualified. Win32 legacy FocusZone cases pass, while its Callout +popup-operation automation remains unqualified. Root/Win32 admission, older-peer builds, popup-family transactions, restoration/Tab policy, text fragments, modal containment, and a real module remain explicit gates. See the repository's `plans/modernization.md` for the diff --git a/packages/agentic/native-core/plans/modernization.md b/packages/agentic/native-core/plans/modernization.md index b0dedd2c435..1cf175672ff 100644 --- a/packages/agentic/native-core/plans/modernization.md +++ b/packages/agentic/native-core/plans/modernization.md @@ -58,8 +58,14 @@ Do not raise legacy React/RN peer floors implicitly. - [x] Shared Swift eligibility/field-editor normalization and placement, Paper/Fabric boundary wiring, owned observer teardown and recycled-popup reparenting. Initial Fabric native qualification passes. -- [ ] Windows generation/native build/runtime qualification. -- [ ] Win32 legacy regression qualification; no new managed transport. +- [x] Windows generation/native build and initial FocusZone command, refusal, + lifetime, and legacy navigation qualification. +- [x] Win32 legacy FocusZone regression and Callout story rendering; no new + managed transport. +- [ ] Remaining Windows FocusZone protocol/inactive-window cases and Callout + runtime/geometry qualification. +- [ ] Win32 legacy Callout popup-operation qualification; its macOS-only + executable case is skipped on Win32. - [ ] Publishing, peer-floor, root-admission decisions. This checklist is updated only with completed work and executed evidence. @@ -98,6 +104,60 @@ Compilation, authored tests, and skipped tests are not native qualification. legacy views until actual reattachment as well as for modern views until deliberate rearm. Recycled popup windows are reparented before showing. +### Executed evidence on Windows + +Executed 2026-10-02 local / 2026-10-03 UTC with Node 24.15.0, React 19.1.4, +React Native 0.81.6, RNW 0.81.35, react-native-win32 0.81.9, and REX 0.81.1. +The Windows x64 Debug build uses MSBuild 18.10.1. The updated FocusZone object +and consolidated `FRNNativeCore.dll` were rebuilt after the source fix; +`FRNNativeCore.winmd` is present in the app output. + +- `yarn workspace @fluentui-react-native/native-core format --check`, + `lint`, `build`, `test:stories`, and `test` pass: 17 Node contracts, + 28 Jest/type tests, and one snapshot. +- `yarn workspace @fluentui-react-native/native-core codegen:windows --check` + passes without generated-source changes. +- `yarn workspace @fluentui-react-native/callout test` and + `yarn workspace @fluentui-react-native/focus-zone test` pass, two tests each. +- From `apps/storybook`, `yarn storybook prep --windows` and + `yarn storybook build --windows` pass with both components autolinked. +- `$env:STORYBOOK_SMOKE_STORY = 'native-*'; yarn storybook smoke --windows --mode stories-and-tests` + passes: 161 stories rendered, nine cases passed, zero failed/skipped. + Four modern FocusZone cases cover actual default/first/last/specific focus, + disabled refusal, native unfocusable/outside-target rejection, pre-aborted + cancellation, and use of a retained command handle after native unmount. + Five legacy FocusZone cases cover directional/spatial navigation and + forward/backward Tab exit. Disabled/pre-aborted/detached results include + wrapper guards; they are not evidence of native generation-mismatch, + pending-request cancellation, or late-result fencing. +- The first native command run returned `refused`: `GettingFocus` redirected + an explicit destination to the entry preference. It now respects the + existing explicit-command/Tab-trap guard. The command case enters the + default target before requesting the edges and later re-enters the first + target, verifying actual focused identity rather than only an outcome label. +- From `apps/storybook`, `yarn storybook bundle --win32` and + `$env:STORYBOOK_SMOKE_STORY = 'native-*'; yarn storybook smoke --win32 --mode stories-and-tests` + pass: 152 stories rendered, five legacy FocusZone cases passed, zero + failures, one macOS-only Callout case skipped. Callout rendering is not + popup-operation qualification. Modern previews remain absent on Win32. +- All desktop production bundles pass, including + `yarn storybook bundle --macos`. New target-lifetime cases have not been + executed on macOS in this Windows session. +- Storybook format/lint, native-core checks, desktop-runtime format/lint/build + and 14 tests, `yarn lint-lockfile`, `yarn build`, + `yarn check-publishing`, and `yarn change:check` pass. +- Initial bundling stopped on duplicate Storybook theming/ui-common copies + after independent upstream dependency bumps. The targeted + `yarn dedupe @storybook/react-native-theming @storybook/react-native-ui-common` + consolidates their compatible ranges to the already-locked 10.6.0 copies; + no new packages, blanket resolution, or duplicate-checker exemption was + added. +- Both owned native smoke lifecycles shut down their app/services; their + channel, Metro, and driver ports have no remaining listeners. + +The Windows Callout story exclusion is unchanged. Native C++ compilation +does not qualify its popup operations, geometry, or resource lifecycle. + ### How to exercise the new API ```tsx @@ -123,9 +183,13 @@ Menu/Popover roadmap. Family transactions, safe restore/owner-relative Tab, text layout providers, modal isolation, actual Menu/Popover integration, disabled discoverability, and a real module remain. -Windows helpers/handlers and local rect/point placement are implemented but -uncompiled on macOS. Validate the actual projected SDK, native ownership, -focus result identity and unmount resources. Windows ancestor transform/scroll +Windows helpers/handlers and local rect/point placement now compile on RNW +0.81.35. Initial FocusZone focus-result identity, scoped target rejection, +detached handles, and legacy navigation have native evidence. Native +generation mismatch/stale result, pending abort/unmount races, target +replacement, inactive-window refusal, and broader resource checks remain. +Windows Callout runtime behavior remains excluded and unqualified. +Windows ancestor transform/scroll tracking still needs full geometry qualification and follow-up; do not infer it from the current frame-summation positioning path. @@ -169,7 +233,7 @@ claim qualification. Inspect the fresh native library/provider and first actionable failure. Record exact commands, versions, pass/fail/skip counts, and whether a case actually exercises the new protocol. -Windows currently includes the two modern FocusZone stories, not modern +Windows currently includes the three modern FocusZone stories, not modern Callout. Win32 includes only legacy native-core stories; modern previews are not imported and no new managed fields/commands are dispatched there. diff --git a/packages/agentic/native-core/src/components/focus-zone/FocusZone.stories.tsx b/packages/agentic/native-core/src/components/focus-zone/FocusZone.stories.tsx index ed4968a23aa..9614a69b700 100644 --- a/packages/agentic/native-core/src/components/focus-zone/FocusZone.stories.tsx +++ b/packages/agentic/native-core/src/components/focus-zone/FocusZone.stories.tsx @@ -63,6 +63,84 @@ function Example(props: FocusZoneProps) { ); } + +function TargetLifetimesExample() { + const commands = React.useRef(null); + const detachedCommands = React.useRef(null); + const unfocusable = useNativeViewTarget(); + const outside = useNativeViewTarget(); + const [mounted, setMounted] = React.useState(true); + const [status, setStatus] = React.useState('Idle'); + const request = async (destination: 'unfocusable' | 'outside' | 'cancelled' | 'detached') => { + const handle = destination === 'detached' ? detachedCommands.current : commands.current; + if (!handle) throw new Error('FocusZone command handle is unavailable.'); + const controller = new AbortController(); + if (destination === 'cancelled') controller.abort(); + const outcome = await handle.requestFocus( + destination === 'unfocusable' ? unfocusable.target : destination === 'outside' ? outside.target : 'first', + { signal: controller.signal }, + ); + setStatus(outcome.status); + }; + return ( + + + {status} + + + {(mounted ? (['unfocusable', 'outside', 'cancelled'] as const) : (['detached'] as const)).map((destination) => ( + { + void request(destination); + }} + > + {destination} + + ))} + {mounted && ( + { + if (!commands.current) throw new Error('FocusZone commands are not attached.'); + detachedCommands.current = commands.current; + setMounted(false); + }} + > + Unmount + + )} + + {mounted && ( + + + Unfocusable target + + + Focusable target + + + )} + + Outside target + + + ); +} + const meta: Meta = { title: 'Native/Modern FocusZone', component: FocusZone, @@ -79,10 +157,12 @@ export const Default: Story = { return; } for (const [strategy, item] of [ + ['default', 2], ['first', 1], ['last', 3], ['default', 2], ['target', 2], + ['first', 1], ] as const) { await (await browser.$(`~modern-focus-request-${strategy}`)).click(); await expect(await browser.$('~modern-focus-status')).toHaveText('confirmed'); @@ -107,8 +187,38 @@ export const Disabled: Story = { }, }, }; +export const TargetLifetimes: Story = { + render: () => , + wdio: { + 'rejects unfocusable and outside live targets': async ({ browser, expect, skip }) => { + if (!browser.capabilities['furn:features']?.physicalClick) { + skip('Physical input unavailable.'); + return; + } + await (await browser.$('~modern-focus-request-unfocusable')).click(); + await expect(await browser.$('~modern-focus-status')).toHaveText('not-focusable'); + await (await browser.$('~modern-focus-request-outside')).click(); + await expect(await browser.$('~modern-focus-status')).toHaveText('not-mounted'); + expect(await (await browser.$('~modern-focus-outside')).getProperty('focused')).toBe(false); + }, + 'cancels pre-aborted requests and invalidates detached commands': async ({ browser, expect, skip }) => { + if (!browser.capabilities['furn:features']?.physicalClick) { + skip('Physical input unavailable.'); + return; + } + await (await browser.$('~modern-focus-request-cancelled')).click(); + await expect(await browser.$('~modern-focus-status')).toHaveText('cancelled'); + expect(await (await browser.$('~modern-focus-item-1')).getProperty('focused')).toBe(false); + await (await browser.$('~modern-focus-unmount')).click(); + await expect(await browser.$('~modern-focus-zone')).not.toExist(); + await (await browser.$('~modern-focus-request-detached')).click(); + await expect(await browser.$('~modern-focus-status')).toHaveText('not-mounted'); + }, + }, +}; const styles = StyleSheet.create({ story: { padding: 24, gap: 16 }, row: { flexDirection: 'row', gap: 12 }, + requests: { flexDirection: 'row', flexWrap: 'wrap', gap: 12 }, button: { padding: 12, borderWidth: 1, borderColor: '#808080' }, }); diff --git a/packages/agentic/native-core/src/components/focus-zone/SPEC.md b/packages/agentic/native-core/src/components/focus-zone/SPEC.md index cfdeebbd524..a0ed1096e61 100644 --- a/packages/agentic/native-core/src/components/focus-zone/SPEC.md +++ b/packages/agentic/native-core/src/components/focus-zone/SPEC.md @@ -15,7 +15,8 @@ Navigation remains platform-native by default; it is not modal containment. `requestFocus` supports default/first/last or a live scoped descendant. Confirmation comes from native responder/focused-component identity, not a -void focus return. Requests are generation-fenced, cancellable, bounded by +void focus return. Entry preferences must not override an explicit command +destination. Requests are generation-fenced, cancellable, bounded by an explicit timeout, and invalidated on component/target replacement. Missing mounts are not queued; inactive windows are not reactivated. Cancellation cannot reverse a mutation already executed natively. diff --git a/packages/agentic/native-core/windows/NativeCore/components/FocusZone/FocusZoneComponentView.cpp b/packages/agentic/native-core/windows/NativeCore/components/FocusZone/FocusZoneComponentView.cpp index 36879f21ebc..aaaa2a0db33 100644 --- a/packages/agentic/native-core/windows/NativeCore/components/FocusZone/FocusZoneComponentView.cpp +++ b/packages/agentic/native-core/windows/NativeCore/components/FocusZone/FocusZoneComponentView.cpp @@ -505,7 +505,7 @@ struct FocusZoneComponentView void HandleGettingFocus( const winrtRN::ComponentView &container, const winrtRN::GettingFocusEventArgs &args) noexcept { - if (Disabled()) { + if (Disabled() || m_inTabTrap) { return; } From df39c2462ed7d7b9f9cac7f43ac46894bfc29b51 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 15 Oct 2026 09:54:35 -0700 Subject: [PATCH 03/15] Rework native-core phased facades and native bridges for authoring rules Make the modern Callout and FocusZone real phased components: hooks, live targets, command channels, imperative handles, and slots run in the first phase, and a hook-free directComponent render phase merges render-time pass-through props. Remove the inner hook-owning components, delete the FocusZoneHost wrapper, restore legacy FocusZone to main, and make the shared CalloutHost a hook-free native binding without the stacked-slot style workaround. Move the new macOS Paper Callout commands to Swift with RCT_EXTERN_METHOD and keep the extern registration free of generated Swift types through a narrow property protocol. Derive the placement edge from the hint in shared Swift, make main-actor isolation explicit, and give notification observers a single owner. On Windows, use a dedicated explicit-command focus flag and an auto-revoked light-dismiss subscription that legacy remounts retain. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/native-core-modern-preview.md | 10 +- packages/agentic/native-core/AGENTS.md | 6 + packages/agentic/native-core/README.md | 24 ++- .../Callout/fabric/RCTCalloutComponentView.mm | 69 +++--- .../Callout/paper/CalloutManager.swift | 33 ++- .../Callout/paper/FRNCalloutManager.h | 5 + .../Callout/paper/FRNCalloutManager.m | 71 ++----- .../Callout/shared/CalloutPlacement.swift | 15 +- .../Callout/shared/CalloutView.swift | 48 +++-- .../Callout/shared/CalloutWindow.swift | 1 + .../shared/FRNNativeCoreFocusService.swift | 6 +- .../native-core/plans/modernization.md | 84 +++++++- .../src/components/callout/Callout.tsx | 200 ++++++++---------- .../src/components/callout/SPEC.md | 6 + .../src/components/focus-zone/FocusZone.tsx | 66 +++--- .../src/components/focus-zone/SPEC.md | 7 + .../src/components/modern.test.tsx | 63 +++++- .../native-core/src/internal/CalloutHost.tsx | 120 ++--------- .../src/internal/FocusZoneHost.tsx | 68 ------ .../src/legacy/callout/Callout.tsx | 50 ++++- .../src/legacy/focus-zone/FocusZone.tsx | 46 +++- .../tests/native-ownership.test.mjs | 18 ++ .../NativeCore/components/Callout/Callout.cpp | 17 +- .../FocusZone/FocusZoneComponentView.cpp | 9 +- 24 files changed, 572 insertions(+), 470 deletions(-) delete mode 100644 packages/agentic/native-core/src/internal/FocusZoneHost.tsx diff --git a/.changeset/native-core-modern-preview.md b/.changeset/native-core-modern-preview.md index 96b637e9890..5f0a145e146 100644 --- a/.changeset/native-core-modern-preview.md +++ b/.changeset/native-core-modern-preview.md @@ -2,10 +2,12 @@ '@fluentui-react-native/native-core': minor --- -Add platform-scoped React 19 Callout and FocusZone previews with live targets, -confirmed cancellable native commands, shared legacy host adapters, Swift -macOS focus/placement services, and a cross-machine validation plan. Preserve -Win32 legacy registrations without new managed transport. +Add platform-scoped React 19 Callout and FocusZone previews built as phased +components, with live targets, confirmed cancellable native commands, a shared +hook-free Callout host, Swift macOS focus/placement services, and a +cross-machine validation plan. Implement the new macOS Paper Callout commands +in Swift behind header-independent bridges. Preserve Win32 legacy +registrations without new managed transport. Honor explicit Windows FocusZone destinations without entry-focus redirection and cover live-target refusal, cancellation, and detached command handles in diff --git a/packages/agentic/native-core/AGENTS.md b/packages/agentic/native-core/AGENTS.md index 40c1898b4ea..b651493cac9 100644 --- a/packages/agentic/native-core/AGENTS.md +++ b/packages/agentic/native-core/AGENTS.md @@ -1,5 +1,8 @@ # Native-core authoring +- Follow the [Apple and Office rules](../../../references/agents/native/apple-office.md) + for shared services and renderer adapters. Paper `RCT_EXTERN_MODULE` files + never import the generated Swift header or name generated Swift classes. - Prefer Swift for new macOS AppKit/domain behavior. Keep ObjC/ObjC++ for generated protocols, C++ conversion, registration, and renderer integration. - Paper and Fabric adapters supply resolved views and event sinks to shared @@ -23,6 +26,9 @@ pre-mount focus requests. - New wrappers use React 19 ref props. FocusZone preserves its native root; Callout's deliberate presentation handle is documented in its contract. +- Use `phasedComponent` for hook-bearing wrappers: run hooks and create slots + in the first phase and return a hook-free `directComponent`. Do not wrap an + inner hook component; hook-driven props are consumed by the first phase. - Keep legacy aliases/registrations and Win32 host ownership. Never send a new managed protocol to a baked host without capability evidence. - Root exports require equivalent mandatory behavior on every supported diff --git a/packages/agentic/native-core/README.md b/packages/agentic/native-core/README.md index c9ebaa9b14f..f76576b4ff1 100644 --- a/packages/agentic/native-core/README.md +++ b/packages/agentic/native-core/README.md @@ -42,11 +42,16 @@ to use its host-provided `RCTCallout` and `RCTFocusZone`. The macOS module-named header at `macos/shared/FRNNativeCore.h` is required by the generated `FRNNativeCore-Swift.h`; keep it aligned with the pod's -`module_name` when changing native ownership. Only Fabric adapters import the -generated header. Hosts that compile the Swift sources with an Objective-C -bridging header also get internal Swift classes declared there, duplicating -Paper `RCT_EXTERN_MODULE` interfaces, so implement Paper manager methods in -Swift and declare them with `RCT_EXTERN_METHOD`. +`module_name` when changing native ownership. Fabric adapters and shared +Objective-C behavior, such as FocusZone's use of the Swift focus service, may +import the generated header. Paper `RCT_EXTERN_MODULE` registration files and +the headers they import must not: hosts that compile the Swift sources with an +Objective-C bridging header also get internal Swift classes declared there, +duplicating the extern interfaces. Implement Paper manager methods in Swift +and declare them with `RCT_EXTERN_METHOD`; custom property bridges use React or +AppKit types and the narrow manager protocol in `FRNCalloutManager.h`. Follow +the repository's [Apple and Office authoring rules](../../../references/agents/native/apple-office.md) +for every macOS change. ## Public entrypoints @@ -276,8 +281,13 @@ preserves its actual native root `ref` and publishes confirmed requests through `commandsRef`. Neither ref assignment nor a void native focus return is confirmation. Requests support abort and timeout, and reject stale lifetimes. -Modern and legacy facades use the same private host components and native -registrations. macOS shares Swift focus eligibility/actual-responder and +Modern and legacy facades share the native registrations; Callout's facades +also share a hook-free private host that applies native-safe positioning and +appearance values. Both modern facades are phased components: the first phase +owns hooks and the presentation/command lifetime, and the hook-free render +phase merges render-time pass-through props. When a modern facade is used as +a slot, supply its first-phase props (listed in each `SPEC.md`) through the +slot props. macOS shares Swift focus eligibility/actual-responder and placement behavior between the renderer adapters; the existing navigation algorithm is not rewritten merely to change language. diff --git a/packages/agentic/native-core/macos/components/Callout/fabric/RCTCalloutComponentView.mm b/packages/agentic/native-core/macos/components/Callout/fabric/RCTCalloutComponentView.mm index 6004f0bb672..b09d02716e1 100644 --- a/packages/agentic/native-core/macos/components/Callout/fabric/RCTCalloutComponentView.mm +++ b/packages/agentic/native-core/macos/components/Callout/fabric/RCTCalloutComponentView.mm @@ -18,31 +18,6 @@ using namespace facebook::react; -static NSRectEdge RCTNSRectEdgeFromDirectionalHint(CalloutDirectionalHint hint) -{ - // CalloutView uses MinY for placement above the anchor and MaxY for placement below it. - switch (hint) { - case CalloutDirectionalHint::LeftTopEdge: - case CalloutDirectionalHint::LeftCenter: - case CalloutDirectionalHint::LeftBottomEdge: - return NSRectEdgeMinX; - case CalloutDirectionalHint::TopLeftEdge: - case CalloutDirectionalHint::TopAutoEdge: - case CalloutDirectionalHint::TopCenter: - case CalloutDirectionalHint::TopRightEdge: - return NSRectEdgeMinY; - case CalloutDirectionalHint::RightTopEdge: - case CalloutDirectionalHint::RightCenter: - case CalloutDirectionalHint::RightBottomEdge: - return NSRectEdgeMaxX; - case CalloutDirectionalHint::BottomLeftEdge: - case CalloutDirectionalHint::BottomAutoEdge: - case CalloutDirectionalHint::BottomCenter: - case CalloutDirectionalHint::BottomRightEdge: - return NSRectEdgeMaxY; - } -} - static void RCTApplyCalloutAppearance( FRNCalloutView *calloutView, const CalloutProps &props, @@ -89,9 +64,15 @@ - (instancetype)initWithFrame:(CGRect)frame _calloutView.onDismiss = ^(__unused NSDictionary *event) { [weakSelf emitOnDismiss]; }; - _calloutView.onReady = ^(NSDictionary *event) { [weakSelf emitReady:event]; }; - _calloutView.onClosed = ^(NSDictionary *event) { [weakSelf emitClosed:event]; }; - _calloutView.onOperationResult = ^(NSDictionary *event) { [weakSelf emitResult:event]; }; + _calloutView.onReady = ^(NSDictionary *event) { + [weakSelf emitReady:event]; + }; + _calloutView.onClosed = ^(NSDictionary *event) { + [weakSelf emitClosed:event]; + }; + _calloutView.onOperationResult = ^(NSDictionary *event) { + [weakSelf emitResult:event]; + }; _touchHandler = [RCTSurfaceTouchHandler new]; [_touchHandler attachToView:_calloutView.contentProxyView]; @@ -119,7 +100,6 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & _calloutView.anchorMode = [NSString stringWithUTF8String:toString(newProps.anchorMode).c_str()]; _calloutView.placementHint = [NSString stringWithUTF8String:toString(newProps.directionalHint).c_str()]; _calloutView.gapSpace = newProps.gapSpace; - _calloutView.directionalHint = RCTNSRectEdgeFromDirectionalHint(newProps.directionalHint); _calloutView.setInitialFocus = newProps.setInitialFocus; const auto &rect = newProps.anchorRect; @@ -178,32 +158,45 @@ - (void)blurWindow - (void)requestFocus:(NSInteger)generation requestId:(NSInteger)requestId targetTag:(NSInteger)targetTag { - [_calloutView requestFocus:generation requestId:requestId - target:FRNNativeCoreFindView(_calloutView.contentProxyView, targetTag)]; + // Resolve the target within this popup's content so a focus request cannot escape the presentation. + NSView *target = FRNNativeCoreFindView(_calloutView.contentProxyView, targetTag); + [_calloutView requestFocus:generation requestId:requestId target:target]; } + - (void)close:(NSInteger)generation requestId:(NSInteger)requestId { [_calloutView close:generation requestId:requestId]; } + - (void)reposition:(NSInteger)generation requestId:(NSInteger)requestId { [_calloutView reposition:generation requestId:requestId]; } + - (void)emitReady:(NSDictionary *)event { - if (_eventEmitter) std::static_pointer_cast(_eventEmitter)->onReady( - {.generation = [event[@"generation"] intValue]}); + if (_eventEmitter) { + std::static_pointer_cast(_eventEmitter)->onReady({.generation = [event[@"generation"] intValue]}); + } } + - (void)emitClosed:(NSDictionary *)event { - if (_eventEmitter) std::static_pointer_cast(_eventEmitter)->onClosed( - {.generation = [event[@"generation"] intValue], .reason = std::string([event[@"reason"] UTF8String])}); + if (_eventEmitter) { + std::static_pointer_cast(_eventEmitter) + ->onClosed({.generation = [event[@"generation"] intValue], .reason = std::string([event[@"reason"] UTF8String])}); + } } + - (void)emitResult:(NSDictionary *)event { - if (_eventEmitter) std::static_pointer_cast(_eventEmitter)->onOperationResult( - {.generation = [event[@"generation"] intValue], .requestId = [event[@"requestId"] intValue], - .status = std::string([event[@"status"] UTF8String])}); + if (_eventEmitter) { + std::static_pointer_cast(_eventEmitter) + ->onOperationResult( + {.generation = [event[@"generation"] intValue], + .requestId = [event[@"requestId"] intValue], + .status = std::string([event[@"status"] UTF8String])}); + } } - (void)updateAnchorView diff --git a/packages/agentic/native-core/macos/components/Callout/paper/CalloutManager.swift b/packages/agentic/native-core/macos/components/Callout/paper/CalloutManager.swift index db9baba205a..5f5e5388b88 100644 --- a/packages/agentic/native-core/macos/components/Callout/paper/CalloutManager.swift +++ b/packages/agentic/native-core/macos/components/Callout/paper/CalloutManager.swift @@ -5,7 +5,7 @@ import React #endif // USE_REACT_AS_MODULE @objc(RCTCalloutManager) -class CalloutManager: RCTViewManager { +class CalloutManager: RCTViewManager, FRNCalloutPropertyManager { override func view()->NSView! { return CalloutView(bridge: bridge) } @@ -28,6 +28,37 @@ class CalloutManager: RCTViewManager { (manager.bridge?.uiManager?.view(forReactTag: viewTag) as? CalloutView)?.blurWindow() } } + + @objc func requestFocus(_ viewTag: NSNumber, generation: Int, requestId: Int, targetTag: Int) { + nonisolated(unsafe) let manager = self + DispatchQueue.main.async { + guard let uiManager = manager.bridge?.uiManager, + let view = uiManager.view(forReactTag: viewTag) as? CalloutView else { return } + view.requestFocus(generation, requestId: requestId, target: uiManager.view(forReactTag: NSNumber(value: targetTag))) + } + } + + @objc func close(_ viewTag: NSNumber, generation: Int, requestId: Int) { + nonisolated(unsafe) let manager = self + DispatchQueue.main.async { + (manager.bridge?.uiManager?.view(forReactTag: viewTag) as? CalloutView)?.close(generation, requestId: requestId) + } + } + + @objc func reposition(_ viewTag: NSNumber, generation: Int, requestId: Int) { + nonisolated(unsafe) let manager = self + DispatchQueue.main.async { + (manager.bridge?.uiManager?.view(forReactTag: viewTag) as? CalloutView)?.reposition(generation, requestId: requestId) + } + } + + // Paper applies view properties on the main queue; the ObjC bridge resolves the tag in this bridge's registry. + @objc(setAnchor:forView:) + func setAnchor(_ anchor: NSView?, for view: NSView) { + MainActor.assumeIsolated { + (view as? CalloutView)?.setAnchorView(anchor) + } + } } // Keep the Paper component name used by older Callout JS bundles. diff --git a/packages/agentic/native-core/macos/components/Callout/paper/FRNCalloutManager.h b/packages/agentic/native-core/macos/components/Callout/paper/FRNCalloutManager.h index 0e547ec2b1e..33ffdec8bdc 100644 --- a/packages/agentic/native-core/macos/components/Callout/paper/FRNCalloutManager.h +++ b/packages/agentic/native-core/macos/components/Callout/paper/FRNCalloutManager.h @@ -3,3 +3,8 @@ #import #import #import + +// Implemented by the Swift Paper manager so custom property bridges never name generated Swift view types. +@protocol FRNCalloutPropertyManager +- (void)setAnchor:(nullable NSView *)anchor forView:(nonnull NSView *)view; +@end diff --git a/packages/agentic/native-core/macos/components/Callout/paper/FRNCalloutManager.m b/packages/agentic/native-core/macos/components/Callout/paper/FRNCalloutManager.m index 88f9dd8a728..6ec26f9adb6 100644 --- a/packages/agentic/native-core/macos/components/Callout/paper/FRNCalloutManager.m +++ b/packages/agentic/native-core/macos/components/Callout/paper/FRNCalloutManager.m @@ -14,25 +14,6 @@ + (NSRect)screenRect:(id)json return NSMakeRect(x, y, width, height); } -// Collapse the directional hint options to the 4 NSRectEdge options. -// CalloutView uses MinY for placement above the anchor and MaxY for placement below it. -RCT_ENUM_CONVERTER(NSRectEdge, (@{ - @"leftTopEdge": @(NSRectEdgeMinX), - @"leftCenter": @(NSRectEdgeMinX), - @"leftBottomEdge": @(NSRectEdgeMinX), - @"topLeftEdge": @(NSRectEdgeMinY), - @"topAutoEdge": @(NSRectEdgeMinY), - @"topCenter": @(NSRectEdgeMinY), - @"topRightEdge": @(NSRectEdgeMinY), - @"rightTopEdge": @(NSRectEdgeMaxX), - @"rightCenter": @(NSRectEdgeMaxX), - @"rightBottomEdge": @(NSRectEdgeMaxX), - @"bottomLeftEdge": @(NSRectEdgeMaxY), - @"bottomAutoEdge": @(NSRectEdgeMaxY), - @"bottomCenter": @(NSRectEdgeMaxY), - @"bottomRightEdge": @(NSRectEdgeMaxY), -}), NSRectEdgeMaxY, integerValue); - @end @interface RCT_EXTERN_MODULE(RCTCalloutManager, RCTViewManager) @@ -41,52 +22,38 @@ @interface RCT_EXTERN_MODULE(RCTCalloutManager, RCTViewManager) RCT_EXTERN_METHOD(blurWindow : (nonnull NSNumber *)viewTag) -RCT_CUSTOM_VIEW_PROPERTY(target, NSNumber, FRNCalloutView) +RCT_EXTERN_METHOD(requestFocus : (nonnull NSNumber *)viewTag generation : (NSInteger)generation requestId : (NSInteger)requestId targetTag : (NSInteger)targetTag) + +RCT_EXTERN_METHOD(close : (nonnull NSNumber *)viewTag generation : (NSInteger)generation requestId : (NSInteger)requestId) + +RCT_EXTERN_METHOD(reposition : (nonnull NSNumber *)viewTag generation : (NSInteger)generation requestId : (NSInteger)requestId) + +RCT_CUSTOM_VIEW_PROPERTY(target, NSNumber, RCTView) { NSNumber *tag = [RCTConvert NSNumber:json]; - NSView *target = tag ? [self.bridge.uiManager viewForReactTag:tag] : nil; - if (tag && !target) RCTLogError(@"Callout target %@ is not mounted in its bridge.", tag); - [view setAnchorView:target]; + NSView *anchor = tag ? [self.bridge.uiManager viewForReactTag:tag] : nil; + if (tag && !anchor) { + RCTLogError(@"Callout target %@ is not mounted in its bridge.", tag); + } + [(id)self setAnchor:anchor forView:view]; } RCT_EXPORT_VIEW_PROPERTY(commandGeneration, NSInteger) + RCT_EXPORT_VIEW_PROPERTY(anchorMode, NSString) + RCT_EXPORT_VIEW_PROPERTY(gapSpace, CGFloat) + RCT_EXPORT_VIEW_PROPERTY(onReady, RCTDirectEventBlock) + RCT_EXPORT_VIEW_PROPERTY(onClosed, RCTDirectEventBlock) -RCT_EXPORT_VIEW_PROPERTY(onOperationResult, RCTDirectEventBlock) -RCT_EXPORT_METHOD(requestFocus:(nonnull NSNumber *)viewTag generation:(NSInteger)generation - requestId:(NSInteger)requestId targetTag:(NSInteger)targetTag) -{ - dispatch_async(dispatch_get_main_queue(), ^{ - FRNCalloutView *view = (FRNCalloutView *)[self.bridge.uiManager viewForReactTag:viewTag]; - if (![view isKindOfClass:[FRNCalloutView class]]) return; - [view requestFocus:generation requestId:requestId target:[self.bridge.uiManager viewForReactTag:@(targetTag)]]; - }); -} -RCT_EXPORT_METHOD(close:(nonnull NSNumber *)viewTag generation:(NSInteger)generation requestId:(NSInteger)requestId) -{ - dispatch_async(dispatch_get_main_queue(), ^{ - FRNCalloutView *view = (FRNCalloutView *)[self.bridge.uiManager viewForReactTag:viewTag]; - if ([view isKindOfClass:[FRNCalloutView class]]) [view close:generation requestId:requestId]; - }); -} -RCT_EXPORT_METHOD(reposition:(nonnull NSNumber *)viewTag generation:(NSInteger)generation requestId:(NSInteger)requestId) -{ - dispatch_async(dispatch_get_main_queue(), ^{ - FRNCalloutView *view = (FRNCalloutView *)[self.bridge.uiManager viewForReactTag:viewTag]; - if ([view isKindOfClass:[FRNCalloutView class]]) [view reposition:generation requestId:requestId]; - }); -} +RCT_EXPORT_VIEW_PROPERTY(onOperationResult, RCTDirectEventBlock) RCT_EXPORT_VIEW_PROPERTY(anchorRect, screenRect) -RCT_CUSTOM_VIEW_PROPERTY(directionalHint, NSRectEdge, FRNCalloutView) -{ - view.placementHint = [json isKindOfClass:[NSString class]] ? json : @""; - view.directionalHint = json ? [RCTConvert NSRectEdge:json] : defaultView.directionalHint; -} +// The shared view derives its preferred edge and alignment from the hint name. +RCT_REMAP_VIEW_PROPERTY(directionalHint, placementHint, NSString) RCT_EXPORT_VIEW_PROPERTY(setInitialFocus, BOOL) diff --git a/packages/agentic/native-core/macos/components/Callout/shared/CalloutPlacement.swift b/packages/agentic/native-core/macos/components/Callout/shared/CalloutPlacement.swift index c11f5f8a34a..29c56f8d9c8 100644 --- a/packages/agentic/native-core/macos/components/Callout/shared/CalloutPlacement.swift +++ b/packages/agentic/native-core/macos/components/Callout/shared/CalloutPlacement.swift @@ -1,8 +1,17 @@ import AppKit struct CalloutPlacement { + /// Preferred anchor edge for a directional hint name; MinY places the popup above the anchor in screen space. + static func edge(for hint: String) -> NSRectEdge { + if hint.hasPrefix("left") { return .minX } + if hint.hasPrefix("right") { return .maxX } + if hint.hasPrefix("top") { return .minY } + return .maxY + } + static func frame(anchor: NSRect, size: NSSize, workArea: NSRect, - edge: NSRectEdge, hint: String, gap: CGFloat, rtl: Bool) -> NSRect { + hint: String, gap: CGFloat, rtl: Bool) -> NSRect { + let preferred = Self.edge(for: hint) func origin(_ edge: NSRectEdge) -> NSPoint { let horizontal = edge == .minY || edge == .maxY let centered = hint.hasSuffix("Center") @@ -20,8 +29,8 @@ struct CalloutPlacement { @unknown default: preconditionFailure("Unknown Callout edge") } } - var frame = NSRect(origin: origin(edge), size: size) - let opposite: NSRectEdge = edge == .minX ? .maxX : edge == .maxX ? .minX : edge == .minY ? .maxY : .minY + var frame = NSRect(origin: origin(preferred), size: size) + let opposite: NSRectEdge = preferred == .minX ? .maxX : preferred == .maxX ? .minX : preferred == .minY ? .maxY : .minY let alternate = NSRect(origin: origin(opposite), size: size) let area = frame.intersection(workArea) let alternateArea = alternate.intersection(workArea) diff --git a/packages/agentic/native-core/macos/components/Callout/shared/CalloutView.swift b/packages/agentic/native-core/macos/components/Callout/shared/CalloutView.swift index 343ca2d9310..49694485d2f 100644 --- a/packages/agentic/native-core/macos/components/Callout/shared/CalloutView.swift +++ b/packages/agentic/native-core/macos/components/Callout/shared/CalloutView.swift @@ -15,7 +15,6 @@ open class CalloutView: RCTView, CalloutWindowLifeCycleDelegate { @objc public var placementHint = "" { didSet { updatePresentation() } } @objc public var gapSpace: CGFloat = 0 { didSet { updatePresentation() } } @objc public var anchorRect: NSRect = .null { didSet { updatePresentation() } } - @objc public var directionalHint: NSRectEdge = .maxY { didSet { updatePresentation() } } @objc public var setInitialFocus = false @objc public var onShow: RCTDirectEventBlock? @objc public var onDismiss: RCTDirectEventBlock? @@ -26,7 +25,7 @@ open class CalloutView: RCTView, CalloutWindowLifeCycleDelegate { private weak var anchorView: NSView? private var popupWindow: CalloutWindow? - private var observers: [NSObjectProtocol] = [] + private let observers = NotificationObservers() private var mouseEventMonitor = GuardedEventMonitor() private var shown = false private var readyGeneration = 0 @@ -47,8 +46,6 @@ open class CalloutView: RCTView, CalloutWindowLifeCycleDelegate { self.bridge = bridge } - deinit { removeObservers() } - public override func viewDidMoveToWindow() { super.viewDidMoveToWindow() if window == nil { dismissReason = "host-detached"; dismissCallout() } @@ -172,13 +169,17 @@ open class CalloutView: RCTView, CalloutWindowLifeCycleDelegate { if setInitialFocus { popup.makeKey() } installObservers() mouseEventMonitor.addLocalMonitorForEvents(matching: .leftMouseDown) { [weak self] event in - guard let self = self else { return event } - func contains(_ window: NSWindow, _ clicked: NSWindow) -> Bool { - window === clicked || (window.childWindows ?? []).contains { contains($0, clicked) } + // AppKit invokes local event monitors on the main thread. + MainActor.assumeIsolated { + guard let self = self else { return } + @MainActor func contains(_ window: NSWindow, _ clicked: NSWindow) -> Bool { + if window === clicked { return true } + return (window.childWindows ?? []).contains { contains($0, clicked) } + } + var root: NSWindow = popup + while let parent = root.parent as? CalloutWindow { root = parent } + if let clicked = event.window, !contains(root, clicked) { self.dismissCallout() } } - var root: NSWindow = popup - while let parent = root.parent as? CalloutWindow { root = parent } - if let clicked = event.window, !contains(root, clicked) { self.dismissCallout() } return event } onShow?([:]) @@ -219,22 +220,19 @@ open class CalloutView: RCTView, CalloutWindowLifeCycleDelegate { proxyView.frame.width > 0, proxyView.frame.height > 0, let area = (anchorView?.window ?? window)?.screen?.visibleFrame else { return false } let frame = CalloutPlacement.frame(anchor: anchor, size: proxyView.frame.size, - workArea: area, edge: directionalHint, hint: placementHint, gap: gapSpace, + workArea: area, hint: placementHint, gap: gapSpace, rtl: NSApp.userInterfaceLayoutDirection == .rightToLeft || RCTI18nUtil.sharedInstance().isRTL()) proxyView.frame.origin = .zero calloutWindow.setFrame(frame, display: false) return true } - private func observe(_ name: Notification.Name, object: Any?, action: @escaping (CalloutView) -> Void) { - observers.append(NotificationCenter.default.addObserver(forName: name, object: object, queue: .main) { - [weak self] _ in if let self = self { action(self) } + private func observe(_ name: Notification.Name, object: Any?, action: @escaping @MainActor @Sendable (CalloutView) -> Void) { + observers.add(NotificationCenter.default.addObserver(forName: name, object: object, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { if let self = self { action(self) } } }) } - private func removeObservers() { - observers.forEach(NotificationCenter.default.removeObserver) - observers.removeAll() - } + private func removeObservers() { observers.removeAll() } private func installObservers() { removeObservers() observe(NSApplication.didResignActiveNotification, object: nil) { $0.dismissCallout() } @@ -280,3 +278,17 @@ open class CalloutView: RCTView, CalloutWindowLifeCycleDelegate { return popup } } + +/// Owns notification registrations and removes any that remain when the owning view is released. +private final class NotificationObservers { + private var tokens: [NSObjectProtocol] = [] + + func add(_ token: NSObjectProtocol) { tokens.append(token) } + + func removeAll() { + tokens.forEach(NotificationCenter.default.removeObserver) + tokens.removeAll() + } + + deinit { removeAll() } +} diff --git a/packages/agentic/native-core/macos/components/Callout/shared/CalloutWindow.swift b/packages/agentic/native-core/macos/components/Callout/shared/CalloutWindow.swift index c66d8c29af4..8071128ad1f 100644 --- a/packages/agentic/native-core/macos/components/Callout/shared/CalloutWindow.swift +++ b/packages/agentic/native-core/macos/components/Callout/shared/CalloutWindow.swift @@ -1,6 +1,7 @@ import Foundation import AppKit +@MainActor protocol CalloutWindowLifeCycleDelegate: AnyObject { /// Notify the delegate that the Callout is about to dismiss func calloutWillDismiss(window: CalloutWindow) diff --git a/packages/agentic/native-core/macos/shared/FRNNativeCoreFocusService.swift b/packages/agentic/native-core/macos/shared/FRNNativeCoreFocusService.swift index 33a38305df1..d2d810e94d9 100644 --- a/packages/agentic/native-core/macos/shared/FRNNativeCoreFocusService.swift +++ b/packages/agentic/native-core/macos/shared/FRNNativeCoreFocusService.swift @@ -2,7 +2,7 @@ import AppKit @objc(FRNNativeCoreFocusService) public final class NativeFocusService: NSObject { - @objc(focusWithin:target:strategy:) + @MainActor @objc(focusWithin:target:strategy:) public static func focus(within root: NSView, target: NSView?, strategy: String) -> String { precondition(Thread.isMainThread, "Native focus must execute on the main thread") guard let window = root.window, window.isVisible, !window.isMiniaturized else { @@ -46,7 +46,7 @@ public final class NativeFocusService: NSObject { return "refused" } - private static func eligible(_ view: NSView) -> Bool { + @MainActor private static func eligible(_ view: NSView) -> Bool { guard view.acceptsFirstResponder, !view.isHiddenOrHasHiddenAncestor, !view.visibleRect.isEmpty else { return false } var current: NSView? = view @@ -58,7 +58,7 @@ public final class NativeFocusService: NSObject { return true } - private static func candidates(in root: NSView) -> [NSView] { + @MainActor private static func candidates(in root: NSView) -> [NSView] { var result: [NSView] = [] for child in root.subviews { if eligible(child) { result.append(child) } diff --git a/packages/agentic/native-core/plans/modernization.md b/packages/agentic/native-core/plans/modernization.md index 1cf175672ff..afe1f9e2716 100644 --- a/packages/agentic/native-core/plans/modernization.md +++ b/packages/agentic/native-core/plans/modernization.md @@ -48,8 +48,11 @@ Do not raise legacy React/RN peer floors implicitly. - [x] Native-sharing instructions and initial modern contracts. - [x] Passive live attachments, callback cleanup, generation fencing and bounded abort/timeout/native-result correlation. -- [x] Private CalloutHost/FocusZoneHost boundary shared by legacy and modern - facades; existing compatibility tests and both shim tests pass. +- [x] Hook-free private CalloutHost shared by legacy and modern Callout; + FocusZone facades share the native registration. Existing compatibility + tests and both shim tests pass. +- [x] Phased modern facades: hooks and slots in the first phase, a hook-free + render phase, and documented first-phase slot props. - [x] Modern FocusZone native ref, commandsRef, enum/default-target translation, scoped default/first/last/specific native requests, and result events. - [x] Modern Callout presentation handles, physical-close latch/rearm, @@ -79,8 +82,8 @@ Compilation, authored tests, and skipped tests are not native qualification. - Both shim tests pass (two tests each); root `yarn build` passes. - Uncached repository `yarn lage test --no-cache --concurrency 4` passes all 82 tasks. A downstream ContextualMenu snapshot caught style-array - normalization through slot ref composition; the shared host now preserves - the legacy style representation without changing that consumer's snapshot. + normalization through slot ref composition; legacy Callout now renders the + hook-free shared host directly, preserving the legacy style representation. - All three final desktop JavaScript bundles pass; Storybook format/lint, package publishing and changeset checks pass. - `codegen:windows --check` passes; new generated command interfaces match @@ -95,15 +98,76 @@ Compilation, authored tests, and skipped tests are not native qualification. - Command stories require explicit owner activation. They do not bypass inactive-window guards or activate a popup merely to inspect it. - Independent lifecycle review found render-time slot props bypassing hooks - and RNW entry redirection overriding explicit requests. The facades now - merge into stable hook-owning implementation components (with a committed - slot regression); explicit RNW commands suppress entry redirection while - retaining actual focused-identity confirmation. + and RNW entry redirection overriding explicit requests. The 2026-10-15 + rework supersedes its inner hook-owning components (see below); explicit + RNW commands suppress entry redirection while retaining actual + focused-identity confirmation. - Native regression also caught a legacy presentation re-opening on a late layout update after dismissal. Closed generations now remain latched for legacy views until actual reattachment as well as for modern views until deliberate rearm. Recycled popup windows are reparented before showing. +### 2026-10-15 rework on macOS + +Owner-requested review against the reorganized component, native, and Apple +authoring guidance. Changes: + +- Modern Callout and FocusZone are real phased components. Their first phase + owns hooks, the operation channel, live targets, imperative handles, and + slots; the hook-free `directComponent` render phase merges render-time + pass-through props. The previous inner hook-owning components (an empty + first phase plus an extra React boundary) are removed. Hook-bound props + (`ref`/`open`/`presentationKey`/`anchor` for Callout, `commandsRef`/ + `defaultTarget` for FocusZone) are consumed by the first phase and must be + supplied through slot props; render-time values are ignored, matching the + legacy facades. FocusZone composes slot, render-time, and internal root refs + through framework-base slot composition. Disabled requests settle through + the native result instead of a duplicate JavaScript check. +- Legacy FocusZone is restored to its `main` implementation. Legacy Callout + keeps its phased hooks and renders the shared hook-free `CalloutHost`; the + stacked-slot `cloneElement` style workaround is removed. +- The Paper Callout extern registration no longer implements methods in + Objective-C or names `FRNCalloutView` (R-APPLE-003/004); its new commands + are Swift with `RCT_EXTERN_METHOD`, the anchor bridge uses the narrow + `FRNCalloutPropertyManager` protocol, and `directionalHint` remaps to the + shared view's `placementHint`. Shared Swift derives the preferred edge from + the hint, so neither adapter maintains a second edge mapping. +- Main-actor isolation is explicit for the Swift focus service, lifecycle + delegate, local mouse monitor, and Paper anchor bridge. Notification + observers have one owner with idempotent cleanup instead of a nonisolated + `deinit` reaching main-actor state. +- Windows: explicit FocusZone commands use a dedicated flag rather than the + Tab-trap flag, restoring main's Tab-exit and remembered-focus behavior; the + Callout light-dismiss subscription uses an `auto_revoke` revoker and is + revoked only with a modern popup, fixing a branch regression where a legacy + Callout lost light dismissal after an unmount/remount of the same view. + These Windows edits were not compiled or run on this Mac. + +Executed: + +- `yarn workspace @fluentui-react-native/native-core test`: build, story + types, 18 Node contracts, 30 Jest/type tests, and one snapshot pass; lint, + format, and `codegen:windows --check` pass. +- From `apps/storybook`: Fabric and Paper `prep`/`build` pass with every + changed native-core object recompiled; default Fabric is restored. +- Office bridging-header probe: no `React` module, strict Swift 5 compile + (RegionBasedIsolation, InferSendableFromCaptures, GlobalConcurrency, + warnings as errors), emitted manager/view/focus-service declarations, and + `-Werror=nonportable-include-path -Werror=unused-parameter` syntax checks of + the extern registration (without the generated header) and both FocusZone + Objective-C sources pass. `-strict-concurrency=complete` leaves only the + pre-existing Paper `view()` warning. +- Fabric and Paper smoke render all 175 stories, including 17 legacy/modern + native stories. Every executable case, including non-native Button, failed + before execution at Desktop Driver session creation with "Accessibility + returned an incomplete or internally inconsistent window enumeration"; + driver permission preflights pass. No runtime command case is claimed. +- macOS, Windows, and Win32 production bundles pass. + +Windows must re-run its build and the modern/legacy FocusZone cases for the +dedicated command flag and revoker; macOS must re-run the native cases once +Desktop Driver window enumeration is healthy. + ### Executed evidence on Windows Executed 2026-10-02 local / 2026-10-03 UTC with Node 24.15.0, React 19.1.4, @@ -131,8 +195,8 @@ and consolidated `FRNNativeCore.dll` were rebuilt after the source fix; wrapper guards; they are not evidence of native generation-mismatch, pending-request cancellation, or late-result fencing. - The first native command run returned `refused`: `GettingFocus` redirected - an explicit destination to the entry preference. It now respects the - existing explicit-command/Tab-trap guard. The command case enters the + an explicit destination to the entry preference. It now respects an + explicit-command guard (a dedicated flag since the 2026-10-15 rework). The command case enters the default target before requesting the edges and later re-enters the first target, verifying actual focused identity rather than only an outcome label. - From `apps/storybook`, `yarn storybook bundle --win32` and diff --git a/packages/agentic/native-core/src/components/callout/Callout.tsx b/packages/agentic/native-core/src/components/callout/Callout.tsx index a4ff884d6a9..098fc155516 100644 --- a/packages/agentic/native-core/src/components/callout/Callout.tsx +++ b/packages/agentic/native-core/src/components/callout/Callout.tsx @@ -1,7 +1,7 @@ /** @jsxImportSource @fluentui-react-native/framework-base */ import * as React from 'react'; import { I18nManager, View } from 'react-native'; -import { directComponent, phasedComponent, useSlot, mergeProps } from '@fluentui-react-native/framework-base'; +import { directComponent, mergeProps, phasedComponent, useSlot } from '@fluentui-react-native/framework-base'; import { CalloutHost } from '../../internal/CalloutHost'; import { assertModernHost } from '../../internal/modernHost'; @@ -15,18 +15,23 @@ import { presentationBrand } from './Callout.types'; class Presentation implements CalloutPresentation { readonly [presentationBrand] = true as const; - private readonly generation: number; + readonly generation: number; constructor(generation: number) { this.generation = generation; } - matches(generation: number) { - return this.generation === generation; - } } -function hint(props: CalloutProps): DirectionalHint { - const side = props.placement?.side ?? 'bottom'; - const align = props.placement?.align ?? 'start'; +interface Lifetime { + open: boolean; + key: string | undefined; + presentation: Presentation; + closed: boolean; + anchored: boolean; +} + +function hint(placement: CalloutProps['placement']): DirectionalHint { + const side = placement?.side ?? 'bottom'; + const align = placement?.align ?? 'start'; const rtl = I18nManager.isRTL; const physical = side === 'start' ? (rtl ? 'right' : 'left') : side === 'end' ? (rtl ? 'left' : 'right') : side; if (physical === 'left' || physical === 'right') { @@ -36,6 +41,12 @@ function hint(props: CalloutProps): DirectionalHint { return `${physical}${edge}`; } +function gapSpace(placement: CalloutProps['placement']): number { + const gap = placement?.gap ?? 0; + if (!Number.isInteger(gap) || gap < 0) throw new RangeError('Callout gap must be a nonnegative integer in logical units.'); + return gap; +} + function rectangle(anchor: CalloutAnchor) { if (anchor.kind === 'view') return undefined; const rect = anchor.kind === 'rect' ? anchor.rect : { ...anchor.point, width: 0, height: 0 }; @@ -50,144 +61,121 @@ function isReason(value: string): value is CalloutDismissReason { return reasons.includes(value); } -function CalloutImplementation(props: CalloutProps) { +/** + * Unstyled native popup. The first phase owns the presentation lifetime, anchor subscription, command channel, and + * presentation handle; when Callout is used as a slot, supply `ref`, `open`, `presentationKey`, and `anchor` through the + * slot props. The render phase merges render-time placement, callbacks, content props, and children. + */ +export const Callout = phasedComponent((props) => { assertModernHost('Callout'); - const target = props.anchor.kind === 'view' ? props.anchor.target : props.anchor.relativeTo; - const tag = useTargetTag(target); + const { ref, open, presentationKey, anchor, content } = props; + const tag = useTargetTag(anchor.kind === 'view' ? anchor.target : anchor.relativeTo); + const anchorRect = rectangle(anchor); const nativeRef = React.useRef>(null); - const channel = React.useMemo(createOperationChannel, []); - const [lifetime, setLifetime] = React.useState(() => ({ - open: props.open, - key: props.presentationKey, - generation: allocateGeneration(), + const [channel] = React.useState(createOperationChannel); + const [lifetime, setLifetime] = React.useState(() => ({ + open, + key: presentationKey, + presentation: new Presentation(allocateGeneration()), closed: false, anchored: false, })); - if (lifetime.open !== props.open || lifetime.key !== props.presentationKey) { - const rearm = (!lifetime.open && props.open) || lifetime.key !== props.presentationKey; + // Derive the presentation lifetime during render so a rearm and its new generation commit together. + if (lifetime.open !== open || lifetime.key !== presentationKey) { + const rearm = (!lifetime.open && open) || lifetime.key !== presentationKey; setLifetime({ - open: props.open, - key: props.presentationKey, - generation: rearm ? allocateGeneration() : lifetime.generation, + open, + key: presentationKey, + presentation: rearm ? new Presentation(allocateGeneration()) : lifetime.presentation, closed: rearm ? false : lifetime.closed, anchored: rearm ? tag !== undefined : lifetime.anchored, }); - } else if (props.open && !lifetime.closed) { + } else if (open && !lifetime.closed) { if (tag !== undefined && !lifetime.anchored) setLifetime({ ...lifetime, anchored: true }); else if (tag === undefined && lifetime.anchored) setLifetime({ ...lifetime, closed: true }); } - const { generation, closed } = lifetime; - const presentation = React.useMemo(() => new Presentation(generation), [generation]); + const { presentation, closed } = lifetime; + const { generation } = presentation; + const presented = open && !closed && tag !== undefined; const committedGeneration = React.useRef(generation); const ready = React.useRef(null); - const Content = useSlot(View, { collapsable: false }); + const Content = useSlot(View, content); React.useLayoutEffect(() => { - if (committedGeneration.current !== generation || !props.open || closed || tag === undefined) ready.current = null; + if (committedGeneration.current !== generation || !presented) ready.current = null; committedGeneration.current = generation; - if (props.open && !closed && tag !== undefined) channel.activate(generation); + if (presented) channel.activate(generation); else channel.cancel(); return channel.cancel; - }, [channel, generation, props.open, closed, tag]); + }, [channel, generation, presented]); - React.useImperativeHandle( - props.ref, - (): CalloutHandle => { - const request = ( - expected: CalloutPresentation, - dispatch: (scope: number, id: number) => void, - options?: Parameters[1], - current?: () => boolean, - ) => { - if (!nativeRef.current) return Promise.resolve({ status: 'not-mounted' as const }); - if (expected !== ready.current) return Promise.resolve({ status: ready.current ? ('stale' as const) : ('not-ready' as const) }); - return channel.request(dispatch, options, current); - }; - return { - getPresentation: () => ready.current, - requestFocus(expected, target, options) { - const resolved = resolveTarget(target); - if (resolved.tag == null) return Promise.resolve({ status: 'not-mounted' }); - return request( - expected, - (scope, id) => { - if (!nativeRef.current) throw new Error('Callout detached before focus dispatch.'); - Commands.requestFocus(nativeRef.current, scope, id, resolved.tag); - }, - options, - resolved.isCurrent, - ); + React.useImperativeHandle(ref, (): CalloutHandle => { + const request = ( + expected: CalloutPresentation, + dispatch: (view: React.ComponentRef, scope: number, id: number) => void, + options?: Parameters[1], + current?: () => boolean, + ) => { + if (!nativeRef.current) return Promise.resolve({ status: 'not-mounted' as const }); + if (expected !== ready.current) return Promise.resolve({ status: ready.current ? ('stale' as const) : ('not-ready' as const) }); + return channel.request( + (scope, id) => { + const view = nativeRef.current; + if (!view) throw new Error('Callout detached before command dispatch.'); + dispatch(view, scope, id); }, - close: (expected, options) => - request( - expected, - (scope, id) => { - if (!nativeRef.current) throw new Error('Callout detached before close dispatch.'); - Commands.close(nativeRef.current, scope, id); - }, - options, - ), - reposition: (expected, options) => - request( - expected, - (scope, id) => { - if (!nativeRef.current) throw new Error('Callout detached before reposition dispatch.'); - Commands.reposition(nativeRef.current, scope, id); - }, - options, - ), - }; - }, - [channel], - ); + options, + current, + ); + }; + return { + getPresentation: () => ready.current, + requestFocus(expected, target, options) { + const resolved = resolveTarget(target); + if (resolved.tag == null) return Promise.resolve({ status: 'not-mounted' }); + return request(expected, (view, scope, id) => Commands.requestFocus(view, scope, id, resolved.tag), options, resolved.isCurrent); + }, + close: (expected, options) => request(expected, (view, scope, id) => Commands.close(view, scope, id), options), + reposition: (expected, options) => request(expected, (view, scope, id) => Commands.reposition(view, scope, id), options), + }; + }, [channel]); - const gap = props.placement?.gap ?? 0; - if (!Number.isInteger(gap) || gap < 0) throw new RangeError('Callout gap must be a nonnegative integer in logical units.'); - const rect = rectangle(props.anchor); + return directComponent((renderProps) => { + if (!presented) return null; + const { placement, onReady, onDismissed, children } = mergeProps(props, renderProps); + const isCurrent = (eventGeneration: number) => eventGeneration === generation && eventGeneration === committedGeneration.current; - { - const renderProps = props; - if (!renderProps.open || closed || tag === undefined) return null; return ( { - if ( - event.nativeEvent.generation !== committedGeneration.current || - !presentation.matches(event.nativeEvent.generation) || - ready.current - ) - return; + if (!isCurrent(event.nativeEvent.generation) || ready.current) return; ready.current = presentation; - renderProps.onReady?.({ presentation }); + onReady?.({ presentation }); }} onClosed={(event) => { - if (event.nativeEvent.generation !== committedGeneration.current || !presentation.matches(event.nativeEvent.generation)) return; + if (!isCurrent(event.nativeEvent.generation)) return; if (!isReason(event.nativeEvent.reason)) throw new Error('Callout received an invalid native dismissal reason.'); ready.current = null; channel.cancel(); - setLifetime((current) => (current.generation === event.nativeEvent.generation ? { ...current, closed: true } : current)); - renderProps.onDismissed?.({ presentation, reason: event.nativeEvent.reason }); + setLifetime((current) => (current.presentation === presentation ? { ...current, closed: true } : current)); + onDismissed?.({ presentation, reason: event.nativeEvent.reason }); }} onOperationResult={(event) => channel.receive(event.nativeEvent)} > - {renderProps.children} + {children} ); - } -} - -export const Callout = phasedComponent((baseProps) => - directComponent((props) => ), -); + }); +}); Callout.displayName = 'Callout'; diff --git a/packages/agentic/native-core/src/components/callout/SPEC.md b/packages/agentic/native-core/src/components/callout/SPEC.md index 2a260ad0cad..1039e867b47 100644 --- a/packages/agentic/native-core/src/components/callout/SPEC.md +++ b/packages/agentic/native-core/src/components/callout/SPEC.md @@ -9,6 +9,12 @@ not the hidden native marker. `content` forwards appearance, accessibility, events and an actual View ref to one materialized attachment host; arbitrary host replacement/collapse is not allowed. +Callout is a phased component. `ref`, `open`, `presentationKey`, and `anchor` +drive hooks in its first phase; when Callout is used as a slot, supply them +through the slot props, because render-time values for them are ignored. +Render-time `placement`, `onReady`, `onDismissed`, and children are merged, and +render-time `content` props compose with the slot's content props and refs. + `open` is externally requested presence. Native closure reports onDismissed and latches that generation closed. Retaining open=true does not reopen. False->true or a changed presentationKey rearms. onReady means attached content diff --git a/packages/agentic/native-core/src/components/focus-zone/FocusZone.tsx b/packages/agentic/native-core/src/components/focus-zone/FocusZone.tsx index 1c06393a711..af527033f4c 100644 --- a/packages/agentic/native-core/src/components/focus-zone/FocusZone.tsx +++ b/packages/agentic/native-core/src/components/focus-zone/FocusZone.tsx @@ -1,23 +1,32 @@ /** @jsxImportSource @fluentui-react-native/framework-base */ import * as React from 'react'; -import { directComponent, phasedComponent, mergeProps } from '@fluentui-react-native/framework-base'; +import type { NativeSyntheticEvent } from 'react-native'; +import { directComponent, mergeProps, phasedComponent, useSlot } from '@fluentui-react-native/framework-base'; -import { FocusZoneHost } from '../../internal/FocusZoneHost'; import { assertModernHost } from '../../internal/modernHost'; -import type NativeFocusZone from '../../specs/components/FocusZoneNativeComponent'; -import { FocusCommands as Commands } from '../../internal/focusZoneCommands'; +import { FocusCommands } from '../../internal/focusZoneCommands'; +import NativeFocusZone from '../../specs/components/FocusZoneNativeComponent'; +import type { NativeOperationResult } from '../nativeOperation'; import { allocateGeneration, createOperationChannel } from '../nativeOperation'; import { resolveTarget, useTargetTag } from '../nativeTarget'; -import type { FocusZoneCommands, FocusZoneProps } from './FocusZone.types'; +import type { FocusZoneCommands, FocusZoneHandle, FocusZoneProps } from './FocusZone.types'; const tabModes = { exit: 'None', native: 'Normal', cycle: 'NavigateWrap', stop: 'NavigateStopAtEnds' } as const; -function FocusZoneImplementation(props: FocusZoneProps) { +/** + * Unstyled native focus zone. The first phase owns the command channel, live default target, and `commandsRef`; when + * FocusZone is used as a slot, supply `commandsRef` and `defaultTarget` through the slot props. The render phase merges + * render-time navigation and view props and composes slot and render-time root refs. + */ +export const FocusZone = phasedComponent((props) => { assertModernHost('FocusZone'); - const nativeRef = React.useRef>(null); - const channel = React.useMemo(createOperationChannel, []); - const generation = React.useMemo(allocateGeneration, []); - const defaultTag = useTargetTag(props.defaultTarget); + const { ref, commandsRef, defaultTarget } = props; + const nativeRef = React.useRef(null); + const [channel] = React.useState(createOperationChannel); + const [generation] = React.useState(allocateGeneration); + const defaultTag = useTargetTag(defaultTarget); + // The internal command ref is returned by the transform so the slot composes it with the slot and render-time refs. + const Root = useSlot(NativeFocusZone, { ref }, { transform: (rootProps) => ({ ...rootProps, ref: nativeRef }) }); React.useLayoutEffect(() => { channel.activate(generation); @@ -25,11 +34,10 @@ function FocusZoneImplementation(props: FocusZoneProps) { }, [channel, generation]); React.useImperativeHandle( - props.commandsRef, + commandsRef, (): FocusZoneCommands => ({ requestFocus(target = 'default', options) { if (!nativeRef.current) return Promise.resolve({ status: 'not-mounted' }); - if (props.disabled) return Promise.resolve({ status: 'not-focusable' }); const resolved = typeof target === 'string' ? undefined : resolveTarget(target); if (resolved && resolved.tag == null) return Promise.resolve({ status: 'not-mounted' }); const strategy = typeof target === 'string' ? target : 'target'; @@ -37,46 +45,44 @@ function FocusZoneImplementation(props: FocusZoneProps) { (scope, requestId) => { const view = nativeRef.current; if (!view) throw new Error('FocusZone detached before command dispatch.'); - Commands.requestFocus(view, scope, requestId, resolved?.tag ?? 0, strategy); + FocusCommands.requestFocus(view, scope, requestId, resolved?.tag ?? 0, strategy); }, options, resolved?.isCurrent, ); }, }), - [channel, props.disabled], + [channel], ); - { + const onOperationResult = (event: NativeSyntheticEvent) => channel.receive(event.nativeEvent); + + return directComponent((renderProps) => { const { - ref, - commandsRef: _commands, - defaultTarget: _target, + ref: _ref, + commandsRef: _commandsRef, + defaultTarget: _defaultTarget, direction = 'both', navigation = 'platform', arrowBoundary = 'stop', tabNavigation = 'exit', ...viewProps - } = props; + } = mergeProps(props, renderProps); + return ( - channel.receive(event.nativeEvent)} - nativeDefaultTarget={defaultTag} + onOperationResult={onOperationResult} + defaultTabbableElement={defaultTag} focusZoneDirection={direction === 'both' ? 'bidirectional' : direction} use2DNavigation={navigation === 'spatial'} navigateAtEnd={arrowBoundary === 'wrap' ? 'NavigateWrap' : 'NavigateStopAtEnds'} tabKeyNavigation={tabModes[tabNavigation]} /> ); - } -} - -export const FocusZone = phasedComponent((baseProps) => - directComponent((props) => ), -); + }); +}); FocusZone.displayName = 'FocusZone'; diff --git a/packages/agentic/native-core/src/components/focus-zone/SPEC.md b/packages/agentic/native-core/src/components/focus-zone/SPEC.md index a0ed1096e61..5340c5d47f9 100644 --- a/packages/agentic/native-core/src/components/focus-zone/SPEC.md +++ b/packages/agentic/native-core/src/components/focus-zone/SPEC.md @@ -9,9 +9,16 @@ the React 19 `ref` prop. `commandsRef` publishes a separate mounted command surface; it is null after unmount. Both native and caller refs compose through framework-base slots, including callback cleanup. +FocusZone is a phased component. `commandsRef` and `defaultTarget` drive hooks +in its first phase; when FocusZone is used as a slot, supply them through the +slot props, because render-time values for them are ignored. Render-time +navigation, `disabled`, and view props are merged, and a render-time `ref` +composes with the slot's `ref`. + Direction, spatial opt-in, arrow wrap, Tab mode, disabled, and a live defaultTarget translate to the existing per-host navigation engine. Navigation remains platform-native by default; it is not modal containment. +A disabled zone reports `not-focusable` through the native command result. `requestFocus` supports default/first/last or a live scoped descendant. Confirmation comes from native responder/focused-component identity, not a diff --git a/packages/agentic/native-core/src/components/modern.test.tsx b/packages/agentic/native-core/src/components/modern.test.tsx index bfb0af441bb..931bce3e87e 100644 --- a/packages/agentic/native-core/src/components/modern.test.tsx +++ b/packages/agentic/native-core/src/components/modern.test.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { Platform, View, Text } from 'react-native'; import { act, fireEvent, render } from '@testing-library/react-native'; -import { useSlot } from '@fluentui-react-native/framework-base'; +import { isPhasedComponent, useSlot } from '@fluentui-react-native/framework-base'; import { Callout } from './callout/Callout'; import type { CalloutHandle, CalloutPresentation } from './callout/Callout.types'; @@ -28,6 +28,11 @@ describe('modern native facades', () => { beforeEach(() => jest.replaceProperty(Platform, 'OS', 'macos')); afterEach(() => jest.restoreAllMocks()); + it('uses phased rendering for both facades', () => { + expect(isPhasedComponent(FocusZone)).toBe(true); + expect(isPhasedComponent(Callout)).toBe(true); + }); + it('preserves the native FocusZone ref and confirms the separate command from a result event', async () => { const nativeRef = React.createRef(); const commandsRef = React.createRef(); @@ -158,35 +163,73 @@ describe('modern native facades', () => { await component.unmount(); }); - it('honors refs, live defaults, and lifecycle props supplied at slot render time', async () => { + it('drives first-phase behavior from slot props and merges render-time props and refs', async () => { const commands = React.createRef(); - const native = React.createRef(); + const slotNative = React.createRef(); + const renderNative = React.createRef(); const presentation = React.createRef(); + const contentRef = React.createRef(); + const ready = jest.fn(); function Fixture({ open }: { open: boolean }) { const target = useNativeViewTarget(); - const Zone = useSlot(FocusZone, {}); - const Popup = useSlot(Callout, {}); + const Zone = useSlot(FocusZone, { ref: slotNative, commandsRef: commands, defaultTarget: target.target }); + const Popup = useSlot(Callout, { + ref: presentation, + open, + anchor: { kind: 'view', target: target.target }, + content: { ref: contentRef, testID: 'content' }, + }); return ( <> - + - + ); } const component = await render(); - expect(native.current).not.toBeNull(); + expect(slotNative.current).not.toBeNull(); + expect(renderNative.current).toBe(slotNative.current); expect(commands.current).not.toBeNull(); expect(presentation.current).not.toBeNull(); - expect(component.getByTestId('zone').props.defaultTabbableElement).toBe(42); - const first = calloutRoot(component).props.commandGeneration; + const zone = component.getByTestId('zone'); + expect(zone.props).toMatchObject({ defaultTabbableElement: 42, focusZoneDirection: 'vertical' }); + expect(zone.props.direction).toBeUndefined(); + expect(zone.props.commandsRef).toBeUndefined(); + const content = component.getByTestId('content'); + expect(content.props.accessibilityLabel).toBe('render-time content'); + expect(contentRef.current).not.toBeNull(); + const root = calloutRoot(component); + expect(root.props).toMatchObject({ directionalHint: 'topLeftEdge', gapSpace: 4 }); + await fireEvent(root, 'ready', { nativeEvent: { generation: root.props.commandGeneration } }); + expect(ready).toHaveBeenCalledTimes(1); + const first = root.props.commandGeneration; await component.rerender(); expect(component.queryByTestId('content')).toBeNull(); await component.rerender(); expect(calloutRoot(component).props.commandGeneration).not.toBe(first); await component.unmount(); + expect(slotNative.current).toBeNull(); + expect(renderNative.current).toBeNull(); + expect(commands.current).toBeNull(); + }); + + it('settles disabled-zone requests through the native result', async () => { + const commandsRef = React.createRef(); + const dispatch = jest.spyOn(FocusCommands, 'requestFocus').mockImplementation(() => undefined); + const component = await render(); + const root = component.getByTestId('zone'); + expect(root.props.disabled).toBe(true); + let result: ReturnType; + await act(async () => { + result = commandsRef.current.requestFocus('first'); + }); + const [, generation, requestId] = dispatch.mock.calls[0]; + await fireEvent(root, 'operationResult', { nativeEvent: { generation, requestId, status: 'not-focusable' } }); + expect(await result).toEqual({ status: 'not-focusable' }); + await component.unmount(); }); }); diff --git a/packages/agentic/native-core/src/internal/CalloutHost.tsx b/packages/agentic/native-core/src/internal/CalloutHost.tsx index 99a5cafd72c..64308b276a2 100644 --- a/packages/agentic/native-core/src/internal/CalloutHost.tsx +++ b/packages/agentic/native-core/src/internal/CalloutHost.tsx @@ -1,24 +1,19 @@ /** @jsxImportSource @fluentui-react-native/framework-base */ -import * as React from 'react'; -import { findNodeHandle, StyleSheet } from 'react-native'; +import type * as React from 'react'; +import { StyleSheet } from 'react-native'; -import { directComponent, mergeProps, phasedComponent, useSlot } from '@fluentui-react-native/framework-base'; +import { directComponent } from '@fluentui-react-native/framework-base'; -import type { CalloutProps } from '../legacy/callout/Callout.types'; -import { calloutName } from '../legacy/callout/Callout.types'; -import NativeCalloutView, { Commands } from '../specs/components/CalloutNativeComponent'; +import type { CalloutTokens } from '../legacy/callout/Callout.types'; +import NativeCalloutView from '../specs/components/CalloutNativeComponent'; import type { NativeProps } from '../specs/components/CalloutNativeComponent'; -export interface CalloutHostProps extends CalloutProps { - ref?: React.Ref>; - nativeRef?: React.Ref>; - nativeTarget?: number; - commandGeneration?: number; - anchorMode?: NativeProps['anchorMode']; - onReady?: NativeProps['onReady']; - onClosed?: NativeProps['onClosed']; - onOperationResult?: NativeProps['onOperationResult']; -} +export type CalloutHostProps = Omit & + CalloutTokens & { + ref?: React.Ref>; + /** Resolved native anchor tag or registered anchor identifier. */ + target?: number | string; + }; const colorTransparent = '#00000000'; @@ -29,70 +24,12 @@ const styles = StyleSheet.create({ }); /** - * Renders the native Callout without applying theme or appearance defaults. + * Hook-free native Callout binding shared by the legacy and modern facades. It applies only the positioning and + * native-safe appearance values the native views require and does not resolve theme defaults. */ -export const CalloutHost = phasedComponent((props) => { - const { componentRef, target } = props; - const nativeComponentRef = React.useRef | null>(null); - const [nativeTarget, setNativeTarget] = React.useState(undefined); - const BaseRoot = useSlot(NativeCalloutView, { ref: nativeComponentRef }); - const Root = useSlot(BaseRoot, props.nativeRef ? { ref: props.nativeRef } : {}); - - React.useImperativeHandle( - componentRef, - () => ({ - blurWindow() { - if (nativeComponentRef.current !== null) { - Commands.blurWindow(nativeComponentRef.current); - } - }, - focusWindow() { - if (nativeComponentRef.current !== null) { - Commands.focusWindow(nativeComponentRef.current); - } - }, - }), - [], - ); - - React.useLayoutEffect(() => { - if (typeof target === 'string') { - setNativeTarget(target); - } else if (target?.current) { - setNativeTarget(findNodeHandle(target.current) ?? undefined); - } else { - setNativeTarget(undefined); - } - }, [target]); - - return directComponent((renderProps) => { - const { - anchorRect, - backgroundColor, - beakWidth, - borderColor, - borderRadius, - borderWidth, - componentRef: _componentRef, - directionalHint, - dismissBehaviors, - gapSpace, - maxHeight, - maxWidth, - minPadding, - minWidth, - style, - target: _target, - nativeTarget: resolvedTarget, - nativeRef: _nativeRef, - ref, - ...nativeProps - } = mergeProps(props, renderProps); - - /** - * Build the style for the callout based on the props. For `borderColor`, `borderWidth`, `backgroundColor`, and - * `borderRadius` defaults values are required otherwise crashes in CalloutView.swift:updateLayer() will occur. - */ +export const CalloutHost = directComponent( + ({ backgroundColor, borderColor, borderRadius, borderWidth, maxHeight, maxWidth, minWidth, style, ...nativeProps }) => { + // The native views require concrete border and background values when drawing the popup layer. const calloutStyle = { backgroundColor: backgroundColor ?? colorTransparent, borderColor: borderColor ?? colorTransparent, @@ -103,29 +40,14 @@ export const CalloutHost = phasedComponent((props) => { ...(minWidth != null && { minWidth }), }; - const nativeStyle = [styles.root, calloutStyle, style]; - const element = ( - ); - // Slot ref composition must not change the legacy style-array shape. - return React.cloneElement(element, { style: nativeStyle }); - }); -}); - -CalloutHost.displayName = calloutName; - -export default CalloutHost; + }, +); diff --git a/packages/agentic/native-core/src/internal/FocusZoneHost.tsx b/packages/agentic/native-core/src/internal/FocusZoneHost.tsx deleted file mode 100644 index 1b1d7704c7e..00000000000 --- a/packages/agentic/native-core/src/internal/FocusZoneHost.tsx +++ /dev/null @@ -1,68 +0,0 @@ -/** @jsxImportSource @fluentui-react-native/framework-base */ -import * as React from 'react'; -import { findNodeHandle } from 'react-native'; - -import { directComponent, mergeProps, phasedComponent, useSlot } from '@fluentui-react-native/framework-base'; -import { useViewCommandFocus } from '@fluentui-react-native/interactive-hooks'; - -import type { FocusZoneProps } from '../legacy/focus-zone/FocusZone.types'; -import { focusZoneName } from '../legacy/focus-zone/FocusZone.types'; -import NativeFocusZone from '../specs/components/FocusZoneNativeComponent'; -import type { NativeSyntheticEvent } from 'react-native'; -import type { NativeOperationResult } from '../components/nativeOperation'; - -export interface FocusZoneHostProps extends FocusZoneProps { - navigateAtEnd?: 'NavigateStopAtEnds' | 'NavigateWrap' | 'NavigateContinue'; - ref?: React.Ref>; - nativeRef?: React.Ref>; - nativeDefaultTarget?: number; - commandGeneration?: number; - onOperationResult?: (event: NativeSyntheticEvent) => void; -} - -/** - * Renders the native FocusZone without applying theme or appearance defaults. - */ -export const FocusZoneHost = phasedComponent((props) => { - const { componentRef, defaultTabbableElement } = props; - const nativeRef = useViewCommandFocus(componentRef); - const BaseRoot = useSlot(NativeFocusZone, { ref: nativeRef }); - const Root = useSlot(BaseRoot, props.nativeRef ? { ref: props.nativeRef } : {}); - const [nativeDefaultTabbableElement, setNativeDefaultTabbableElement] = React.useState(); - - React.useLayoutEffect(() => { - if (typeof defaultTabbableElement === 'string') { - setNativeDefaultTabbableElement(defaultTabbableElement); - } else if (defaultTabbableElement?.current) { - setNativeDefaultTabbableElement(findNodeHandle(defaultTabbableElement.current) ?? undefined); - } else { - setNativeDefaultTabbableElement(undefined); - } - }, [defaultTabbableElement]); - - return directComponent((renderProps) => { - const { - componentRef: _componentRef, - defaultTabbableElement: _defaultTabbableElement, - nativeDefaultTarget, - nativeRef: _nativeRef, - ref, - isCircularNavigation, - navigateAtEnd = isCircularNavigation ? 'NavigateWrap' : 'NavigateStopAtEnds', - ...nativeProps - } = mergeProps(props, renderProps) as FocusZoneHostProps & { - navigateAtEnd?: 'NavigateStopAtEnds' | 'NavigateWrap' | 'NavigateContinue'; - }; - - return ( - - ); - }); -}); - -FocusZoneHost.displayName = focusZoneName; diff --git a/packages/agentic/native-core/src/legacy/callout/Callout.tsx b/packages/agentic/native-core/src/legacy/callout/Callout.tsx index d2ef4d3f1f0..bc496fb2a22 100644 --- a/packages/agentic/native-core/src/legacy/callout/Callout.tsx +++ b/packages/agentic/native-core/src/legacy/callout/Callout.tsx @@ -1,13 +1,57 @@ /** @jsxImportSource @fluentui-react-native/framework-base */ +import * as React from 'react'; +import { findNodeHandle } from 'react-native'; + import { directComponent, mergeProps, phasedComponent } from '@fluentui-react-native/framework-base'; import { CalloutHost } from '../../internal/CalloutHost'; import type { CalloutProps } from './Callout.types'; import { calloutName } from './Callout.types'; +import type NativeCalloutView from '../../specs/components/CalloutNativeComponent'; +import { Commands } from '../../specs/components/CalloutNativeComponent'; + +/** + * Renders the native Callout without applying theme or appearance defaults. + */ +export const Callout = phasedComponent((props) => { + const { componentRef, target } = props; + const nativeComponentRef = React.useRef | null>(null); + const [nativeTarget, setNativeTarget] = React.useState(undefined); + + React.useImperativeHandle( + componentRef, + () => ({ + blurWindow() { + if (nativeComponentRef.current !== null) { + Commands.blurWindow(nativeComponentRef.current); + } + }, + focusWindow() { + if (nativeComponentRef.current !== null) { + Commands.focusWindow(nativeComponentRef.current); + } + }, + }), + [], + ); -export const Callout = phasedComponent((baseProps) => - directComponent((props) => ), -); + React.useLayoutEffect(() => { + if (typeof target === 'string') { + setNativeTarget(target); + } else if (target?.current) { + setNativeTarget(findNodeHandle(target.current) ?? undefined); + } else { + setNativeTarget(undefined); + } + }, [target]); + + return directComponent((renderProps) => { + const { componentRef: _componentRef, target: _target, ...hostProps } = mergeProps(props, renderProps); + + return ; + }); +}); Callout.displayName = calloutName; + export default Callout; diff --git a/packages/agentic/native-core/src/legacy/focus-zone/FocusZone.tsx b/packages/agentic/native-core/src/legacy/focus-zone/FocusZone.tsx index 32ecc49f40e..0430f6c1123 100644 --- a/packages/agentic/native-core/src/legacy/focus-zone/FocusZone.tsx +++ b/packages/agentic/native-core/src/legacy/focus-zone/FocusZone.tsx @@ -1,12 +1,50 @@ /** @jsxImportSource @fluentui-react-native/framework-base */ +import * as React from 'react'; +import { findNodeHandle } from 'react-native'; + import { directComponent, mergeProps, phasedComponent } from '@fluentui-react-native/framework-base'; +import { useViewCommandFocus } from '@fluentui-react-native/interactive-hooks'; -import { FocusZoneHost } from '../../internal/FocusZoneHost'; import type { FocusZoneProps } from './FocusZone.types'; import { focusZoneName } from './FocusZone.types'; +import NativeFocusZone from '../../specs/components/FocusZoneNativeComponent'; + +/** + * Renders the native FocusZone without applying theme or appearance defaults. + */ +export const FocusZone = phasedComponent((props) => { + const { componentRef, defaultTabbableElement } = props; + const nativeRef = useViewCommandFocus(componentRef); + const [nativeDefaultTabbableElement, setNativeDefaultTabbableElement] = React.useState(); + + React.useLayoutEffect(() => { + if (typeof defaultTabbableElement === 'string') { + setNativeDefaultTabbableElement(defaultTabbableElement); + } else if (defaultTabbableElement?.current) { + setNativeDefaultTabbableElement(findNodeHandle(defaultTabbableElement.current) ?? undefined); + } else { + setNativeDefaultTabbableElement(undefined); + } + }, [defaultTabbableElement]); + + return directComponent((renderProps) => { + const { + componentRef: _componentRef, + defaultTabbableElement: _defaultTabbableElement, + isCircularNavigation, + navigateAtEnd = isCircularNavigation ? 'NavigateWrap' : 'NavigateStopAtEnds', + ...nativeProps + } = mergeProps(props, renderProps) as FocusZoneProps & { navigateAtEnd?: 'NavigateStopAtEnds' | 'NavigateWrap' | 'NavigateContinue' }; -export const FocusZone = phasedComponent((baseProps) => - directComponent((props) => ), -); + return ( + + ); + }); +}); FocusZone.displayName = focusZoneName; diff --git a/packages/agentic/native-core/tests/native-ownership.test.mjs b/packages/agentic/native-core/tests/native-ownership.test.mjs index a3e22cb234d..e47eb5dbca2 100644 --- a/packages/agentic/native-core/tests/native-ownership.test.mjs +++ b/packages/agentic/native-core/tests/native-ownership.test.mjs @@ -60,12 +60,30 @@ test('macOS Swift interoperability uses the consolidated pod module name', async // Bridging-header builds also declare internal Swift classes in the generated header, duplicating the Paper // manager's RCT_EXTERN_MODULE interface, so Paper commands are implemented in Swift instead. const paper = await readFile(new URL('macos/components/Callout/paper/FRNCalloutManager.m', packageRoot), 'utf8'); + const paperHeader = await readFile(new URL('macos/components/Callout/paper/FRNCalloutManager.h', packageRoot), 'utf8'); const manager = await readFile(new URL('macos/components/Callout/paper/CalloutManager.swift', packageRoot), 'utf8'); assert.doesNotMatch(paper, /-Swift\.h/); + assert.doesNotMatch(paperHeader, /-Swift\.h/); + // The extern registration must not name generated Swift types or implement methods in Objective-C. + assert.doesNotMatch(paper, /\bFRNCalloutView\b/); + assert.doesNotMatch(paper, /RCT_EXPORT_METHOD\(/); for (const command of ['focusWindow', 'blurWindow']) { assert.match(paper, new RegExp(`RCT_EXTERN_METHOD\\(${command} : \\(nonnull NSNumber \\*\\)viewTag\\)`)); assert.match(manager, new RegExp(`@objc func ${command}\\(_ viewTag: NSNumber\\)`)); } + for (const command of ['requestFocus', 'close', 'reposition']) { + assert.match( + paper, + new RegExp(`RCT_EXTERN_METHOD\\(${command} : \\(nonnull NSNumber \\*\\)viewTag generation : \\(NSInteger\\)generation requestId`), + ); + assert.match(manager, new RegExp(`@objc func ${command}\\(_ viewTag: NSNumber, generation: Int, requestId: Int`)); + } + // Custom property bridges use React/AppKit types and a narrow manager protocol implemented in Swift. + assert.match(paper, /RCT_CUSTOM_VIEW_PROPERTY\(target, NSNumber, RCTView\)/); + assert.match(paper, /RCT_REMAP_VIEW_PROPERTY\(directionalHint, placementHint, NSString\)/); + assert.match(paperHeader, /@protocol FRNCalloutPropertyManager/); + assert.match(manager, /class CalloutManager: RCTViewManager, FRNCalloutPropertyManager/); + assert.match(manager, /@objc\(setAnchor:forView:\)\s*func setAnchor\(_ anchor: NSView\?, for view: NSView\)/); }); test('macOS Paper registers the legacy FRNCallout alias through the current manager', async () => { diff --git a/packages/agentic/native-core/windows/NativeCore/components/Callout/Callout.cpp b/packages/agentic/native-core/windows/NativeCore/components/Callout/Callout.cpp index 8d8b07c0fec..d853b7a94dd 100644 --- a/packages/agentic/native-core/windows/NativeCore/components/Callout/Callout.cpp +++ b/packages/agentic/native-core/windows/NativeCore/components/Callout/Callout.cpp @@ -69,7 +69,6 @@ struct CalloutComponentView winrt::Windows::Foundation::IInspectable>, Codegen::BaseCallout { ~CalloutComponentView() { - if (m_lightDismiss && m_lightDismissToken.value) m_lightDismiss.Dismissed(m_lightDismissToken); if (m_popup && !m_popup.IsClosed()) { /* // Unregister closing event handler @@ -269,10 +268,9 @@ struct CalloutComponentView if (Modern()) ClosePresentation("host-detached"); m_mounted = false; ++m_lifetime; - if (m_lightDismiss && m_lightDismissToken.value) m_lightDismiss.Dismissed(m_lightDismissToken); - m_lightDismissToken = {}; - m_lightDismiss = nullptr; if (Modern()) { + // Modern presentations own their popup per mount; legacy views keep the popup and its subscription. + m_lightDismissRevoker.revoke(); if (m_rnWindow) m_rnWindow.Close(); if (m_popup && !m_popup.IsClosed()) m_popup.Close(); m_rnWindow = nullptr; @@ -319,11 +317,11 @@ struct CalloutComponentView m_rnWindow.ResizePolicy( winrt::Microsoft::ReactNative::ContentSizePolicy::None); - m_lightDismiss = + m_lightDismissRevoker = winrt::Microsoft::UI::Input::InputLightDismissAction::GetForWindowId( - m_rnWindow.AppWindow().Id()); - m_lightDismissToken = m_lightDismiss.Dismissed( - {get_weak(), &CalloutComponentView::OnLightDismissDismissed}); + m_rnWindow.AppWindow().Id()) + .Dismissed(winrt::auto_revoke, + {get_weak(), &CalloutComponentView::OnLightDismissDismissed}); if (portal.ContentRoot().Children().Size()) { AdjustWindowSize( @@ -506,8 +504,7 @@ struct CalloutComponentView uint64_t m_lifetime{0}; HWND m_parentHwnd{nullptr}; winrt::weak_ref m_portal; - winrt::Microsoft::UI::Input::InputLightDismissAction m_lightDismiss{nullptr}; - winrt::event_token m_lightDismissToken; + winrt::Microsoft::UI::Input::InputLightDismissAction::Dismissed_revoker m_lightDismissRevoker; winrt::event_token m_childLayoutMetricsToken; winrt::Microsoft::UI::Content::DesktopPopupSiteBridge m_popup{nullptr}; winrt::Microsoft::ReactNative::ReactNativeWindow m_rnWindow{nullptr}; diff --git a/packages/agentic/native-core/windows/NativeCore/components/FocusZone/FocusZoneComponentView.cpp b/packages/agentic/native-core/windows/NativeCore/components/FocusZone/FocusZoneComponentView.cpp index aaaa2a0db33..22403abe495 100644 --- a/packages/agentic/native-core/windows/NativeCore/components/FocusZone/FocusZoneComponentView.cpp +++ b/packages/agentic/native-core/windows/NativeCore/components/FocusZone/FocusZoneComponentView.cpp @@ -77,10 +77,10 @@ struct FocusZoneComponentView else if (!props || generation <= 0 || props->commandGeneration.value_or(0) != generation) status = "stale"; else if (Disabled()) status = "not-focusable"; else { - const bool previousTrap = m_inTabTrap; - m_inTabTrap = true; + // An explicit command destination must not be redirected to the remembered or default entry element. + m_explicitFocusRequest = true; status = ::FRNNativeCore::FocusWithin(view, targetTag, strategy, DefaultTabbableTag()); - m_inTabTrap = previousTrap; + m_explicitFocusRequest = false; } if (auto emitter = EventEmitter()) emitter->onOperationResult({generation, requestId, status}); } @@ -505,7 +505,7 @@ struct FocusZoneComponentView void HandleGettingFocus( const winrtRN::ComponentView &container, const winrtRN::GettingFocusEventArgs &args) noexcept { - if (Disabled() || m_inTabTrap) { + if (Disabled() || m_explicitFocusRequest) { return; } @@ -598,6 +598,7 @@ struct FocusZoneComponentView winrt::weak_ref m_view; int64_t m_lastFocusedTag{0}; bool m_inTabTrap{false}; + bool m_explicitFocusRequest{false}; }; } // namespace winrt::FRNFocusZone From 5705e14f1c474ba6f58b5243cb4c57c0d486027e Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 15 Oct 2026 11:11:37 -0700 Subject: [PATCH 04/15] Record macOS native runtime evidence for the native-core rework Fabric passes all 11 native Callout/FocusZone cases; Paper cases are blocked by the Desktop Driver's missing enabled state for Paper views. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../native-core/plans/modernization.md | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/agentic/native-core/plans/modernization.md b/packages/agentic/native-core/plans/modernization.md index afe1f9e2716..6df7efeb7d5 100644 --- a/packages/agentic/native-core/plans/modernization.md +++ b/packages/agentic/native-core/plans/modernization.md @@ -158,15 +158,26 @@ Executed: Objective-C sources pass. `-strict-concurrency=complete` leaves only the pre-existing Paper `view()` warning. - Fabric and Paper smoke render all 175 stories, including 17 legacy/modern - native stories. Every executable case, including non-native Button, failed - before execution at Desktop Driver session creation with "Accessibility - returned an incomplete or internally inconsistent window enumeration"; - driver permission preflights pass. No runtime command case is claimed. + native stories. While the machine was locked, every executable case, + including non-native Button, failed at Desktop Driver session creation with + "Accessibility returned an incomplete or internally inconsistent window + enumeration". +- After unlocking, `STORYBOOK_SMOKE_STORY='native-*' yarn storybook smoke +--macos --mode stories-and-tests` (Fabric) renders 175 stories and passes all + 11 native cases with zero skips: legacy Callout open/dismiss/recreate, five + legacy FocusZone navigation/Tab cases, modern Callout readiness/focus/close/ + rearm, and four modern FocusZone command, disabled, live-target, and + detached-handle cases. +- Paper (`--paper`) renders 175 stories, and every executed case (legacy + Callout, both legacy FocusZone default cases, modern Callout operations) + fails at its first story-control click: the macOS automation provider + "does not report an enabled state" for Paper views. This is the known + Desktop Driver Paper gap, so Paper runtime remains unqualified. - macOS, Windows, and Win32 production bundles pass. Windows must re-run its build and the modern/legacy FocusZone cases for the -dedicated command flag and revoker; macOS must re-run the native cases once -Desktop Driver window enumeration is healthy. +dedicated command flag and revoker. Paper runtime cases need Desktop Driver +enabled-state support for Paper views before they can execute. ### Executed evidence on Windows From 385d36fdf0587b1e15d44b8533f3dc713e15f8c4 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 15 Oct 2026 13:31:54 -0700 Subject: [PATCH 05/15] Make the Desktop Driver reliable and diagnosable on macOS Paper - Treat macOS elements without AXEnabled (React Native Paper views that are not disabled) as enabled. - Diagnose locked or switched-out login sessions before launch, attach, window discovery, and new physical input. - Record window notifications, element descriptors, and a command journal on one clock; expose them through furn/window-events, publish window-closed events, and settle window listing while notifications arrive. - Activate through AXFrontmost instead of cooperative workspace activation, journal activation details, and name the occluder of intercepted clicks. - Share failure evidence capture that reads window events first and captures secondary windows, and use it for Storybook wdio callbacks. - Give attached WebdriverIO sessions explicit wait defaults so waitUntil polls until its timeout. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/desktop-driver-paper-session.md | 19 + .../storybook-desktop-failure-evidence.md | 7 + .../agentic/desktop-driver/native/PROTOCOL.md | 8 + .../desktop-driver/native/macos/README.md | 67 ++- .../DesktopDriverHost/Accessibility.swift | 35 +- .../DesktopDriverHost/Application.swift | 129 ++++- .../Sources/DesktopDriverHost/Driver.swift | 97 +++- .../Sources/DesktopDriverHost/Host.swift | 26 + .../Sources/DesktopDriverHost/Input.swift | 18 +- .../DesktopDriverHost/Permissions.swift | 1 + .../Sources/DesktopDriverHost/SelfTest.swift | 105 ++++ .../Sources/DesktopDriverHost/Session.swift | 87 +++ .../DesktopDriverHost/WindowEvents.swift | 518 ++++++++++++++++++ .../references/ci-integration.md | 4 +- .../desktop-driver/references/protocol.md | 15 +- .../references/test-integration.md | 17 +- .../src/artifacts/ArtifactManager.ts | 4 + .../src/artifacts/failureEvidence.test.ts | 93 ++++ .../src/artifacts/failureEvidence.ts | 87 +++ .../desktop-driver/src/artifacts/index.ts | 2 + .../desktop-driver/src/authoring/results.ts | 2 +- .../src/client/DesktopDriverClient.ts | 12 +- .../agentic/desktop-driver/src/host/types.ts | 25 + .../src/hosts/fake/FakeDesktopHost.ts | 22 + .../src/hosts/native/NativeDesktopHost.ts | 5 + packages/agentic/desktop-driver/src/index.ts | 2 + .../src/runner/StoryTestRunner.ts | 32 +- .../src/server/createDesktopDriverServer.ts | 12 +- .../src/server/desktopDriver.test.ts | 28 + .../src/wdio/DesktopWebdriver.test.ts | 2 + .../src/wdio/DesktopWebdriver.ts | 6 +- .../src/wdio/wdioRunner.contract.cjs | 11 +- .../src/testing/runWdioTests.ts | 25 +- .../src/testing/wdioRunner.contract.cjs | 3 +- 34 files changed, 1428 insertions(+), 98 deletions(-) create mode 100644 .changeset/desktop-driver-paper-session.md create mode 100644 .changeset/storybook-desktop-failure-evidence.md create mode 100644 packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Session.swift create mode 100644 packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/WindowEvents.swift create mode 100644 packages/agentic/desktop-driver/src/artifacts/failureEvidence.test.ts create mode 100644 packages/agentic/desktop-driver/src/artifacts/failureEvidence.ts diff --git a/.changeset/desktop-driver-paper-session.md b/.changeset/desktop-driver-paper-session.md new file mode 100644 index 00000000000..c86c2817265 --- /dev/null +++ b/.changeset/desktop-driver-paper-session.md @@ -0,0 +1,19 @@ +--- +'@fluentui-react-native/desktop-driver': patch +--- + +Report macOS elements that do not publish `AXEnabled`, such as React Native +Paper views without an explicit disabled state, as enabled so they can be +clicked and queried. Diagnose a locked or switched-out macOS login session: +launch, attach, window discovery, and new physical input fail immediately with +an explicit session diagnosis, and `doctor --permissions` reports the session. + +Record macOS window notifications and recent helper commands in a bounded +diagnostic timeline exposed through `furn/window-events`, publish +`window-closed` events, and describe transient windows by role, frame, and +WindowServer identity. Activate macOS targets through Accessibility instead of +waiting about a second for cooperative workspace activation, and name the +occluder of an intercepted click. Add `captureDesktopFailureEvidence`, which +captures window events first and then the current and other open windows. +Attached WebdriverIO sessions now use explicit wait defaults so `waitUntil` +polls until its timeout instead of stopping after the first poll. diff --git a/.changeset/storybook-desktop-failure-evidence.md b/.changeset/storybook-desktop-failure-evidence.md new file mode 100644 index 00000000000..11c8676c278 --- /dev/null +++ b/.changeset/storybook-desktop-failure-evidence.md @@ -0,0 +1,7 @@ +--- +'@fluentui-react-native/storybook-desktop': patch +--- + +Capture the Desktop Driver's shared failure evidence for Storybook `wdio` +callbacks, including the window-event timeline, a failure screenshot, and the +screenshot and source of other open windows. diff --git a/packages/agentic/desktop-driver/native/PROTOCOL.md b/packages/agentic/desktop-driver/native/PROTOCOL.md index 8c3ffc8fab3..267fdfb7d0f 100644 --- a/packages/agentic/desktop-driver/native/PROTOCOL.md +++ b/packages/agentic/desktop-driver/native/PROTOCOL.md @@ -156,6 +156,14 @@ events: Queries remain authoritative. Events can invalidate state or wake waits but do not replace a liveness or state query. +Helpers that advertise the `windowEvents` feature also accept a `windowEvents` +command with the target `lease` and return a bounded diagnostic log of recent +window notifications and helper commands, and publish a `window-closed` event +with `{ "windowId": "..." }` when the platform reports a tracked window +destroyed. Helpers without the feature reject the command as +`unsupported-operation`; Node never requests the log unless the feature is +negotiated. + ### `cancel` and `cancelled` When a command's `AbortSignal` fires, Node sends: diff --git a/packages/agentic/desktop-driver/native/macos/README.md b/packages/agentic/desktop-driver/native/macos/README.md index ca3888e6abf..be6df555b0a 100644 --- a/packages/agentic/desktop-driver/native/macos/README.md +++ b/packages/agentic/desktop-driver/native/macos/README.md @@ -24,6 +24,7 @@ Accessibility. | `InputLock.swift` | Per-user cross-process physical-input lock and abandoned-owner recovery | | `Capture.swift` | ScreenCaptureKit window capture, element crop, scale, and PNG encoding | | `Permissions.swift` | TCC preflight/prompt diagnostics and Security.framework signing evidence | +| `Session.swift` | Login-session lock and console diagnostics | | `Support.swift` | Errors, cancellation, JSON helpers, deadlines, and AX utilities | | `SelfTest.swift` | Provider-local framing, identity, AX, input, capture, and permission checks | @@ -177,7 +178,38 @@ An AX result must be a real `AXWindow` or `AXSheet`. The provider rejects an `AXApplication` placeholder even if `AXIsProcessTrusted()` is true. It checks the windows, main-window, and focused-window attributes, refreshes replaced window identities, and uses CoreGraphics only as a uniquely correlated geometry -fallback. +fallback. Listing windows requires a readable `AXWindows` result and +role-verified candidates; a focused or main window missing from `AXWindows`, as +happens briefly while key status settles after a popup closes, is listed as a +candidate instead of failing. Attaching by title still requires every fallback +window to appear in `AXWindows` so uniqueness can be proven. Transient +enumeration failures are retried for up to one second, or up to three seconds +while window notifications are still arriving, before they are reported. A +tracked window that Accessibility reports destroyed is no longer listed. + +## Window events and command journal + +Each observed application has an `AXObserver` on a dedicated run-loop thread, +so notifications arrive while the worker thread executes commands. The monitor +records window created, destroyed, focused-window, main-window, minimize, and +application activation notifications in a 512-entry ring. Live window events +carry a descriptor read when the notification arrives: role, subrole, role +description, identifier, title, frame, parent, child count, and the matching +WindowServer window's number, layer, alpha, on-screen state, and name. +Activation events record which application owns system-wide keyboard focus. +Destroyed notifications for tracked windows close them and publish the FDR1 +`window-closed` event. + +A 128-entry command journal records each helper command's start, duration, +outcome, and window or element identifiers on the same monotonic clock. It +never records text, values, or native handles. Activation annotates its entry +with the method used, elapsed time, and the previously focused application. +The `windowEvents` command returns both logs with `ageMs` values for +`furn/window-events` and failure evidence. Both are diagnostics only. + +Descriptor reads set a messaging timeout on the notification's own element. +Never set one on the system-wide element: `AXUIElementSetMessagingTimeout` +applies a system-wide timeout to every Accessibility request in the process. ## Accessibility and input @@ -189,12 +221,38 @@ The AX provider supports: - active element, focus, hit testing, clear, and accessibility activation; - primary and secondary application windows. +AppKit omits `AXEnabled` from elements that have no disabled state, and React +Native macOS Paper views publish it only when `accessibilityState.disabled` is +set. The provider therefore reports `enabled: true` when an element's attribute +list does not contain `AXEnabled`, matching how assistive technologies treat +it. An element that lists `AXEnabled` without a readable value still reports +an unsupported enabled state. Fabric views always publish the attribute. + Quartz Event Services supplies physical pointer, keyboard, text, and wheel input. Physical input requires Accessibility/PostEvent authority for the responsible process and an unlocked interactive Aqua session. Missing authority returns `input-unavailable`; the provider never substitutes unrelated accessibility activation and reports physical success. +A locked screen or a login session that is not the active console prevents +applications from publishing complete accessibility windows and from receiving +physical input. Launch, attach, window discovery, and new pointer, keyboard, +text, or wheel input check the login session and fail immediately with +`input-unavailable` and a `session` diagnostic instead of retrying into a +window-enumeration error. Key and button releases remain allowed so recovery +can complete while the session is locked. + +Activation sets the application's `AXFrontmost` attribute, which a trusted +client applies immediately, and falls back to `NSRunningApplication.activate` +only if that fails. Cooperative workspace activation was observed to wait about +a second for the active application to yield. Activation completes when the +application reports `AXFrontmost` and the primary window reports `AXMain`; +a timeout names the application that kept keyboard focus. + +A physical click first hit-tests its target. When another element owns the +point, `element click intercepted` names the occluding element, or the external +application and process that own it. + React Native macOS does not move keyboard focus on ordinary mouse-down. Portable tests should assert the platform's actual activation behavior rather than copying a Windows focus expectation. @@ -243,6 +301,8 @@ The versioned JSON reports: - Screen Recording preflight state; - cdhash, signer, team identifier, and designated requirement when Security.framework exposes them; +- the login session: whether it is on the console, finished logging in, and + locked, read from `CGSessionCopyCurrentDictionary`; - whether each value is available and whether this mode can prompt for it. Values that cannot be read are marked unavailable rather than inferred. @@ -317,7 +377,7 @@ FURN_DESKTOP_DRIVER_MACOS_SIGNING_IDENTITY="" \ The Swift self-test covers framing, JSON, input ledger and lock recovery, capture scaling, snapshot normalization, AX diagnostics, CoreGraphics fallback -classification, permission diagnostics, and hello identity. The Node native +classification, permission and login-session diagnostics, and hello identity. The Node native contract additionally builds, verifies, reuses, and directly resolves the app; starts the actual long-lived process; and exercises malformed-frame recovery. @@ -358,10 +418,13 @@ remain in [PLAN.md](../../PLAN.md). | Signature or designated-requirement mismatch | Select the intended identity, rebuild, and do not mutate cached output | | Accessibility true but no real AX window | Preserve diagnostics, restart/retry the app and helper, and do not accept an `AXApplication` placeholder | | Physical input unavailable | Grant the actual responsible process Accessibility/PostEvent access and use a logged-in Aqua session | +| Screen locked or session not on console | Unlock or switch to the session, confirm `session` in `doctor --permissions`, and retry | | Screen capture unavailable | Grant Screen & System Audio Recording, restart the helper, and rerun noninteractive doctor | | Ambiguous application or window | Correct the registered identity or owner lease; never choose the first match | | Input lock reports an abandoned owner | Ensure the prior process is gone, use the verified helper's release mode, then retry | | Element becomes stale after a Storybook reset | Resolve it again in the new preview generation | +| Unexpected extra window after a popup closes | Read `failure-window-events.json` and `failure.png`; a focused `AXSheet` can be an app error overlay | +| Activation is slow or fails | Check the `activate` journal entry's `method`, `elapsedMs`, and focused application | See [Native helpers](../../references/native-helpers.md) for cache and resolution policy, [CI integration](../../references/ci-integration.md) for diff --git a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Accessibility.swift b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Accessibility.swift index 493aff18941..eca47bf821b 100644 --- a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Accessibility.swift +++ b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Accessibility.swift @@ -570,11 +570,7 @@ final class AccessibilityEngine { } else { text = nil } - let enabled = try supportedBool( - element, - attribute: kAXEnabledAttribute, - reason: "The provider does not report an enabled state." - ) + let enabled = try enabledState(element) let focused = try supportedBool( element, attribute: kAXFocusedAttribute, @@ -615,6 +611,24 @@ final class AccessibilityEngine { ) } + // AppKit omits AXEnabled from elements that have no disabled state, and React Native macOS Paper views publish it + // only when accessibilityState.disabled is set. Assistive technologies treat such elements as enabled. An element + // that advertises AXEnabled without a readable value remains unsupported. + private func enabledState(_ element: AXUIElement) throws -> SupportedBool { + if let enabled = try optionalBool(element, attribute: kAXEnabledAttribute) { + return .supported(enabled) + } + var names: CFArray? + if AXUIElementCopyAttributeNames(element, &names) == .success, + let names = names as? [String], + !names.isEmpty, + !names.contains(kAXEnabledAttribute) + { + return .supported(true) + } + return .unsupported("The provider does not report an enabled state.") + } + private func supportedBool( _ element: AXUIElement, attribute: String, @@ -854,6 +868,15 @@ final class AccessibilityEngine { ) -> ElementSnapshot { let screenRect = (try? axFrame(hit)) ?? .zero let windowFrame = (try? window.currentFrame()) ?? .zero + var ownerID: pid_t = 0 + var name = "External desktop window" + if AXUIElementGetPid(hit, &ownerID) == .success { + let owner = processIdentity(ownerID) + let ownerName = owner["name"] as? String ?? "unknown application" + name = ownerID == window.processID + ? "Another window of the target application" + : "External desktop window of \(ownerName) (pid \(ownerID))" + } return ElementSnapshot( id: IdentifierGenerator.shared.next("external-window"), automationID: nil, @@ -861,7 +884,7 @@ final class AccessibilityEngine { enabled: .supported(true), expanded: .unsupported("An external desktop window does not expose expanded state."), focused: .supported(false), - name: "External desktop window", + name: name, parentID: nil, rect: CGRect( x: screenRect.minX - windowFrame.minX, diff --git a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Application.swift b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Application.swift index 0af415a097f..a917c639832 100644 --- a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Application.swift +++ b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Application.swift @@ -140,6 +140,7 @@ private func windowElementDiagnostic(_ element: AXUIElement) -> JSONObject { "attributeNames": boundedNames, "attributeNamesError": axErrorJSON(namesError), "attributeNamesTruncated": allNames.count > boundedNames.count, + "element": "ax:\(CFHash(element))", "roleError": axErrorJSON(roleError), "titleError": axErrorJSON(titleError), ] @@ -225,11 +226,15 @@ func classifyAXWindowQuery( return "matched" } -func incompleteAXCandidateDiscovery( +/// Window listing needs a readable AXWindows result and role-verified candidates. A focused or main window that is +/// missing from AXWindows, as happens while key status settles after a popup closes, is still a verified candidate. +func unreliableAXWindowEnumeration( queries: [AXWindowAttributeDiagnostic], - roleQueries: [AXWindowRoleDiagnostic], - titleErrorCount: Int + roleQueries: [AXWindowRoleDiagnostic] ) -> Bool { + guard queries.contains(where: { $0.attribute == kAXWindowsAttribute }) else { + return true + } let windowQueryIncomplete = queries.contains { $0.attribute == kAXWindowsAttribute && ($0.error != .success || !$0.valueValid) } @@ -238,7 +243,16 @@ func incompleteAXCandidateDiscovery( || !$0.valueValid || ($0.role != kAXWindowRole && $0.role != kAXSheetRole) } - if titleErrorCount > 0 || windowQueryIncomplete || roleQueryIncomplete { + return windowQueryIncomplete || roleQueryIncomplete +} + +/// Proving that a title is unique additionally requires AXWindows to contain every fallback window. +func incompleteAXCandidateDiscovery( + queries: [AXWindowAttributeDiagnostic], + roleQueries: [AXWindowRoleDiagnostic], + titleErrorCount: Int +) -> Bool { + if titleErrorCount > 0 || unreliableAXWindowEnumeration(queries: queries, roleQueries: roleQueries) { return true } guard let authoritative = queries.first(where: { $0.attribute == kAXWindowsAttribute }) else { @@ -287,9 +301,12 @@ final class ApplicationManager { private var leaseOrder: [String] = [] private var windows: [String: WindowRecord] = [:] private var windowIDsByHash: [CFHashCode: [String]] = [:] + let windowEvents = WindowEventMonitor() + let commands = CommandJournal() func launch(_ params: JSONObject, token: CancellationToken) throws -> ApplicationLeaseRecord { let application = try requireObject(params["application"], "The launch request is missing its application descriptor.") + try failIfSessionUnavailable("Launching the application") let bundleIdentifier = optionalString(application, "bundleIdentifier") let executablePath = optionalString(application, "executablePath") let windowTitle = optionalString(application, "windowTitle") @@ -371,6 +388,7 @@ final class ApplicationManager { func attach(_ params: JSONObject, token: CancellationToken) throws -> ApplicationLeaseRecord { let application = try requireObject(params["application"], "The attach request is missing its application descriptor.") + try failIfSessionUnavailable("Attaching to the application") let endpoint = optionalString(params, "endpoint") ?? "macos" let configuredBundleIdentifier = optionalString(application, "bundleIdentifier") let configuredExecutablePath = optionalString(application, "executablePath") @@ -657,13 +675,30 @@ final class ApplicationManager { try fail(ErrorCode.noSuchLease, "The leased application process has exited or changed identity.") } let geometryDeadline = DispatchTime.now() + .seconds(1) + // Window notifications can extend enumeration retries while publication is still changing, within a hard bound. + let settleDeadline = DispatchTime.now() + .seconds(3) var results: [WindowRecord] = [] var candidateCount = 0 var zeroGeometryCount = 0 var geometryDiagnostics: [JSONObject] = [] while results.isEmpty { - let trackedElements = windows.values.filter { $0.leaseID == leaseID }.map(\.element) - let elements = uniqueAXElements(trackedElements + (try accessibilityWindows(processID: lease.processID))) + // A destroyed notification proves a tracked window closed; other tracked windows remain candidates. + let trackedElements = windows.values + .filter { $0.leaseID == leaseID && !windowEvents.isClosed(windowID: $0.id) } + .map(\.element) + let discovered: [AXUIElement] + do { + discovered = try accessibilityWindows(processID: lease.processID) + } catch let error as HelperError + where error.code == ErrorCode.automationFailed + && (DispatchTime.now() < geometryDeadline + || (windowEvents.isSettling(processID: lease.processID) && DispatchTime.now() < settleDeadline)) + { + // A closing popup can briefly leave AX window publication inconsistent; retry while it settles. + try token.wait(milliseconds: 50) + continue + } + let elements = uniqueAXElements(trackedElements + discovered) candidateCount = elements.count zeroGeometryCount = 0 for element in elements { @@ -706,9 +741,10 @@ final class ApplicationManager { try token.wait(milliseconds: 50) } if results.isEmpty, candidateCount > 0 { - try fail( + try failWindowDiscovery( ErrorCode.automationFailed, "The application exposes \(candidateCount) live accessibility window candidate(s), but \(zeroGeometryCount) have empty geometry.", + processID: lease.processID, data: [ "candidateCount": candidateCount, "coreGraphicsQueries": geometryDiagnostics, @@ -763,6 +799,7 @@ final class ApplicationManager { } func forgetWindow(_ windowID: String) { + windowEvents.forget(windowID: windowID) guard let record = windows.removeValue(forKey: windowID) else { return } @@ -810,6 +847,7 @@ final class ApplicationManager { ) leases[lease.id] = lease leaseOrder.append(lease.id) + windowEvents.observe(processID: running.processIdentifier) let primary = try uniqueWindow( processID: running.processIdentifier, exactTitle: windowTitle, @@ -840,6 +878,7 @@ final class ApplicationManager { if existing.primary { lease.primaryWindowID = id } + windowEvents.track(element, windowID: id, processID: lease.processID) return existing } } @@ -853,6 +892,7 @@ final class ApplicationManager { ) windows[record.id] = record windowIDsByHash[hash, default: []].append(record.id) + windowEvents.track(element, windowID: record.id, processID: lease.processID) if primary { lease.primaryWindowID = record.id } @@ -867,14 +907,42 @@ final class ApplicationManager { } record.element = element windowIDsByHash[CFHash(element), default: []].append(record.id) + windowEvents.track(element, windowID: record.id, processID: record.processID) } private func removeLease(_ leaseID: String) { for id in windowIDs(for: leaseID) { forgetWindow(id) } - leases.removeValue(forKey: leaseID) + let processID = leases.removeValue(forKey: leaseID)?.processID leaseOrder.removeAll { $0 == leaseID } + if let processID, !leases.values.contains(where: { $0.processID == processID }) { + windowEvents.stop(processID: processID) + } + } + + /// Recent window notifications and the current AX publication for a lease's application. + func windowEventLog(_ leaseID: String) throws -> JSONObject { + guard let lease = leases[leaseID] else { + try fail(ErrorCode.noSuchLease, "The requested application lease is not held by this helper.") + } + var log = windowEvents.diagnostic(processID: lease.processID, limit: WindowEventMonitor.maximumEvents) + log["commands"] = commands.diagnostic() + let outcome = queryAccessibilityWindows(processID: lease.processID) + log["publication"] = outcome.diagnostic(reason: "observation") + log["trackedWindows"] = windows.values + .filter { $0.leaseID == leaseID } + .sorted { $0.id < $1.id } + .map { record -> JSONObject in + [ + "closed": windowEvents.isClosed(windowID: record.id), + "element": "ax:\(CFHash(record.element))", + "id": record.id, + "primary": record.primary, + "title": bounded(record.title, bytes: 512), + ] + } + return log } private func readLease(path: String, token: CancellationToken) throws -> JSONObject { @@ -1035,7 +1103,7 @@ final class ApplicationManager { missingCode: String ) throws -> AXUIElement { let outcome = queryAccessibilityWindows(processID: processID) - try throwCriticalWindowQueryFailure(outcome, code: missingCode) + try throwCriticalWindowQueryFailure(outcome, code: missingCode, proveUniqueness: true) var matches: [AXUIElement] = [] var titleErrorCount = 0 var titleErrors: [JSONObject] = [] @@ -1115,9 +1183,10 @@ final class ApplicationManager { data["titleErrorCount"] = titleErrorCount data["titleErrors"] = titleErrors data["titleErrorsTruncated"] = titleErrorCount > titleErrors.count - try fail( + try failWindowDiscovery( missingCode, message, + processID: processID, data: data ) } @@ -1164,7 +1233,11 @@ final class ApplicationManager { ) } - private func throwCriticalWindowQueryFailure(_ outcome: AXWindowQueryOutcome, code: String) throws { + private func throwCriticalWindowQueryFailure( + _ outcome: AXWindowQueryOutcome, + code: String, + proveUniqueness: Bool = false + ) throws { let reason = classifyAXWindowQuery( queries: outcome.queries, realWindowCount: outcome.realWindows.count, @@ -1181,39 +1254,55 @@ final class ApplicationManager { ) } if reason == "target-non-response" { - try fail( + try failWindowDiscovery( code, "The target application did not complete one or more accessibility window queries.", + processID: outcome.processID, data: outcome.diagnostic(reason: reason) ) } if reason == "invalid-element" { - try fail( + try failWindowDiscovery( code, "The target application's accessibility object became invalid during window discovery.", + processID: outcome.processID, data: outcome.diagnostic(reason: reason) ) } - if incompleteAXCandidateDiscovery( - queries: outcome.queries, - roleQueries: outcome.roleQueries, - titleErrorCount: 0 - ) { - try fail( + let incomplete = proveUniqueness + ? incompleteAXCandidateDiscovery(queries: outcome.queries, roleQueries: outcome.roleQueries, titleErrorCount: 0) + : unreliableAXWindowEnumeration(queries: outcome.queries, roleQueries: outcome.roleQueries) + if incomplete { + try failWindowDiscovery( code, "Accessibility returned an incomplete or internally inconsistent window enumeration.", + processID: outcome.processID, data: outcome.diagnostic(reason: "incomplete-window-enumeration") ) } if reason == "window-query-failed" { - try fail( + try failWindowDiscovery( code, "Accessibility window discovery failed before the target returned a usable window.", + processID: outcome.processID, data: outcome.diagnostic(reason: reason) ) } } + // Locked or switched-out sessions make AX window publication incomplete; report that cause instead of a symptom. + private func failWindowDiscovery( + _ code: String, + _ message: String, + processID: pid_t, + data: JSONObject + ) throws -> Never { + var diagnostic = data + diagnostic["windowEvents"] = windowEvents.diagnostic(processID: processID) + try failIfSessionUnavailable("Accessibility window discovery", data: diagnostic) + try fail(code, message, data: diagnostic) + } + private func queryWindowAttribute( _ application: AXUIElement, attribute: String diff --git a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Driver.swift b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Driver.swift index f7578b70bb0..a28043d54dd 100644 --- a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Driver.swift +++ b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Driver.swift @@ -16,8 +16,53 @@ final class Driver { private let capture = CaptureEngine() private let inputLock = PhysicalInputLock.shared private var persistentInputLock = false + // Commands execute serially on the worker thread, so one current journal entry is sufficient. + private var journalEntry: Int? + + /// Publishes advisory `window-closed` events for tracked windows that Accessibility reports destroyed. + func setWindowClosedPublisher(_ publish: @escaping (String) -> Void) { + applications.windowEvents.publishWindowClosed = publish + } func execute(command: String, params: JSONObject, token: CancellationToken) throws -> CommandResult { + let entry = applications.commands.begin(command, detail: journalDetail(params)) + journalEntry = entry + defer { journalEntry = nil } + do { + let result = try executeCommand(command: command, params: params, token: token) + applications.commands.finish(entry, outcome: "ok", detail: journalResult(command, result.result)) + return result + } catch let error as HelperError { + applications.commands.finish(entry, outcome: error.code) + throw error + } catch is CancelledError { + applications.commands.finish(entry, outcome: "cancelled") + throw CancelledError() + } catch { + applications.commands.finish(entry, outcome: "internal-error") + throw error + } + } + + // Journal only identifiers needed to correlate commands with window events, never text or native handles. + private func journalDetail(_ params: JSONObject) -> JSONObject { + var detail: JSONObject = [:] + for key in ["elementId", "windowId", "mode"] { + if let value = params[key] as? String { + detail[key] = bounded(value, bytes: 128) + } + } + return detail + } + + private func journalResult(_ command: String, _ result: Any) -> JSONObject { + guard command == "windows", let windows = result as? [JSONObject] else { + return [:] + } + return ["windowIds": windows.compactMap { $0["id"] as? String }] + } + + private func executeCommand(command: String, params: JSONObject, token: CancellationToken) throws -> CommandResult { try token.throwIfCancelled() switch command { case "probe": @@ -38,6 +83,9 @@ final class Driver { let lease = try requireObject(params["lease"], "The windows request is missing its lease.") let records = try applications.listWindows(try requireString(lease, "id"), token: token) return CommandResult(result: try records.map(windowJSON)) + case "windowEvents": + let lease = try requireObject(params["lease"], "The windowEvents request is missing its lease.") + return CommandResult(result: try applications.windowEventLog(try requireString(lease, "id"))) case "closeWindow": let windowID = try requireString(params, "windowId") let window = try applications.requireWindow(windowID) @@ -191,6 +239,7 @@ final class Driver { "screenshot": screenshot, "setWindowRect": trusted, "wheel": trusted, + "windowEvents": trusted, ], "platformName": "macos", "protocolVersion": 1, @@ -233,27 +282,67 @@ final class Driver { guard let application = NSRunningApplication(processIdentifier: window.processID) else { try fail(ErrorCode.noSuchWindow, "The requested window's application is no longer running.") } - _ = application.activate(options: []) + let started = DispatchTime.now() + let applicationElement = AXUIElementCreateApplication(window.processID) + var diagnostics: JSONObject = [:] + if let focused = focusedApplicationIdentity() { + diagnostics["focusedApplicationBefore"] = focused + } + func isFrontmost() -> Bool { + axNumber(try? copyOptionalAXAttribute(applicationElement, kAXFrontmostAttribute))?.boolValue == true + } + if !isFrontmost() { + // Trusted Accessibility clients activate immediately. NSRunningApplication activation is cooperative on current + // macOS releases and was observed to wait about a second for the active application to yield. + let frontmostError = AXUIElementSetAttributeValue(applicationElement, kAXFrontmostAttribute as CFString, kCFBooleanTrue) + diagnostics["method"] = frontmostError == .success ? "accessibility" : "workspace" + if frontmostError != .success { + diagnostics["accessibilityError"] = axErrorName(frontmostError) + _ = application.activate(options: []) + } + } else { + diagnostics["method"] = "already-frontmost" + } _ = AXUIElementSetAttributeValue(window.element, kAXMainAttribute as CFString, kCFBooleanTrue) let raised = AXUIElementPerformAction(window.element, kAXRaiseAction as CFString) if raised != .success && raised != .actionUnsupported { try fail(ErrorCode.windowActivation, "Raising the application window failed because \(describeAXError(raised)).") } let deadline = DispatchTime.now() + .seconds(2) + var main = false + var frontmost = false while DispatchTime.now() < deadline { try token.throwIfCancelled() - let main = axNumber(try copyAXAttribute(window.element, kAXMainAttribute))?.boolValue == true - if NSWorkspace.shared.frontmostApplication?.processIdentifier == window.processID && (main || !window.primary) { + main = axNumber(try copyAXAttribute(window.element, kAXMainAttribute))?.boolValue == true + frontmost = isFrontmost() + if frontmost && (main || !window.primary) { + diagnostics["elapsedMs"] = Int((DispatchTime.now().uptimeNanoseconds - started.uptimeNanoseconds) / 1_000_000) + annotateJournal(diagnostics) return } try token.wait(milliseconds: 50) } + diagnostics["frontmost"] = frontmost + diagnostics["main"] = main + if let focused = focusedApplicationIdentity() { + diagnostics["focusedApplication"] = focused + } + annotateJournal(diagnostics) + let owner = (diagnostics["focusedApplication"] as? JSONObject)?["name"] as? String try fail( ErrorCode.windowActivation, - "The target window did not become the active foreground application window." + "The target window did not become the active foreground application window" + + (owner.map { " (\($0) kept keyboard focus)." } ?? "."), + data: diagnostics ) } + private func annotateJournal(_ detail: JSONObject) { + if let journalEntry { + applications.commands.annotate(journalEntry, detail: detail) + } + } + private func setWindowRect( _ window: WindowRecord, requested: JSONObject, diff --git a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Host.swift b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Host.swift index b65284ea222..e3b8eaef9e6 100644 --- a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Host.swift +++ b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Host.swift @@ -30,10 +30,23 @@ func createHello() -> JSONObject { "source", "tree", "wheel", + "windowEvents", ], ] } +private final class EventSequence: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + + func next() -> Int { + lock.lock() + defer { lock.unlock() } + value += 1 + return value + } +} + private struct PendingRequest { let id: String let command: String @@ -53,6 +66,19 @@ final class Host { func run() -> Int32 { let writer = FrameWriter(.standardOutput) self.writer = writer + let eventSequence = EventSequence() + driver.setWindowClosedPublisher { windowID in + do { + try writer.writeJSON([ + "event": "window-closed", + "payload": ["windowId": windowID], + "sequence": eventSequence.next(), + "type": "event", + ]) + } catch { + writeDiagnostic("furn-desktop-driver-host: publishing a window event failed: \(error)") + } + } let reader = FrameReader(.standardInput) do { try writer.writeJSON(createHello()) diff --git a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Input.swift b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Input.swift index ab5d4a86b15..e960ba529ac 100644 --- a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Input.swift +++ b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Input.swift @@ -74,7 +74,7 @@ final class InputController { } func movePointer(to target: CGPoint, durationMilliseconds: Int, token: CancellationToken) throws { - try requirePhysicalInput() + try requireInteractiveInput() if !pointerInitialized { syncPointer() } @@ -104,7 +104,7 @@ final class InputController { } func pointerDown(_ button: Int, token: CancellationToken) throws { - try requirePhysicalInput() + try requireInteractiveInput() try token.throwIfCancelled() _ = try mouseButton(button) if pressedButtons.contains(button) { @@ -141,7 +141,7 @@ final class InputController { } func wheel(deltaX: Double, deltaY: Double, token: CancellationToken) throws { - try requirePhysicalInput() + try requireInteractiveInput() try token.throwIfCancelled() guard let event = CGEvent( scrollWheelEvent2Source: source, @@ -157,7 +157,7 @@ final class InputController { } func keyDown(_ value: String, token: CancellationToken) throws { - try requirePhysicalInput() + try requireInteractiveInput() try token.throwIfCancelled() guard isSingleWebDriverKey(value) else { try fail(ErrorCode.invalidParams, "A keyDown value must contain exactly one Unicode code point.") @@ -187,7 +187,7 @@ final class InputController { } func typeText(_ text: String, token: CancellationToken) throws { - try requirePhysicalInput() + try requireInteractiveInput() for scalar in text.unicodeScalars { try token.throwIfCancelled() let value = String(scalar) @@ -253,7 +253,7 @@ final class InputController { } private func chord(_ strokes: [KeyStroke], token: CancellationToken) throws { - try requirePhysicalInput() + try requireInteractiveInput() let base = pressedKeys.count for (index, stroke) in strokes.enumerated() { try token.throwIfCancelled() @@ -276,6 +276,12 @@ final class InputController { } } + // Starting new interaction also needs an unlocked console session; releases must remain possible while locked. + private func requireInteractiveInput() throws { + try requirePhysicalInput() + try failIfSessionUnavailable("Physical input") + } + private func postKey(_ stroke: KeyStroke, down: Bool) throws { let event: CGEvent? switch stroke { diff --git a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Permissions.swift b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Permissions.swift index 5951c77a2c5..200058a1827 100644 --- a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Permissions.swift +++ b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Permissions.swift @@ -149,6 +149,7 @@ func createPermissionDiagnostic(prompt: Bool) -> JSONObject { ], "promptRequested": prompt, "schemaVersion": 1, + "session": LoginSessionState.current().json, "signing": signingEvidence(), "type": "permissions", ] diff --git a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/SelfTest.swift b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/SelfTest.swift index 7e3005f7b31..4de5615e2ed 100644 --- a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/SelfTest.swift +++ b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/SelfTest.swift @@ -28,6 +28,9 @@ private final class SelfTestRunner { testCoreGraphicsWindowSelection() testReactNativeAccessibilityFallbacks() testPermissionDiagnostic() + testLoginSessionState() + testWindowEventMonitor() + testCommandJournal() testHello() } @@ -407,6 +410,21 @@ private final class SelfTestRunner { ), "fallback windows absent from AXWindows make discovery incomplete" ) + check( + !unreliableAXWindowEnumeration(queries: [authoritativeWindows, inconsistentFallback], roleQueries: []), + "a role-verified fallback window absent from AXWindows does not invalidate window listing" + ) + check( + unreliableAXWindowEnumeration(queries: [query(.cannotComplete)], roleQueries: []), + "an unreadable AXWindows result invalidates window listing" + ) + check( + unreliableAXWindowEnumeration( + queries: [authoritativeWindows], + roleQueries: [AXWindowRoleDiagnostic(error: .invalidUIElement, role: nil, valueValid: false)] + ), + "an unverifiable window candidate invalidates window listing" + ) check( !incompleteAXMultiProcessDiscovery( queriesByCandidate: [ @@ -542,6 +560,93 @@ private final class SelfTestRunner { signing?["designatedRequirement"] is JSONObject, "permission diagnostics explicitly report designated-requirement availability" ) + let session = diagnostic["session"] as? JSONObject + check(session?["source"] as? String == LoginSessionState.source, "permission diagnostics report login-session state") + } + + private func testWindowEventMonitor() { + let monitor = WindowEventMonitor() + let element = AXUIElementCreateApplication(getpid()) + var published: [String] = [] + monitor.publishWindowClosed = { published.append($0) } + check(monitor.diagnostic(processID: getpid())["observing"] as? Bool == false, "an unobserved process reports no window observer") + monitor.track(element, windowID: "window-test", processID: getpid()) + monitor.record(element: element, notification: kAXFocusedWindowChangedNotification) + check(monitor.isSettling(processID: getpid()), "a recent focus change marks window publication as settling") + check(!monitor.isClosed(windowID: "window-test"), "focus changes do not close a tracked window") + monitor.record(element: element, notification: kAXUIElementDestroyedNotification) + monitor.record(element: element, notification: kAXUIElementDestroyedNotification) + check(monitor.isClosed(windowID: "window-test"), "a destroyed notification closes the tracked window") + check(published == ["window-test"], "a tracked window close is published exactly once") + let events = monitor.diagnostic(processID: getpid())["events"] as? [JSONObject] ?? [] + check( + events.count == 3 && events.last?["windowId"] as? String == "window-test" + && events.last?["notification"] as? String == kAXUIElementDestroyedNotification, + "the window event timeline records notifications with resolved window identities" + ) + check( + events.first?["descriptor"] is JSONObject && events.last?["descriptor"] == nil, + "live window events carry an element descriptor while destroyed events do not" + ) + monitor.forget(windowID: "window-test") + check(!monitor.isClosed(windowID: "window-test"), "forgetting a window clears its closed state") + for _ in 0..<(WindowEventMonitor.maximumEvents + 10) { + monitor.record(element: element, notification: kAXApplicationActivatedNotification) + } + check( + monitor.diagnostic(processID: getpid(), limit: WindowEventMonitor.maximumEvents)["eventsTruncated"] as? Bool == false + && (monitor.diagnostic(processID: getpid(), limit: WindowEventMonitor.maximumEvents)["events"] as? [JSONObject])?.count + == WindowEventMonitor.maximumEvents, + "the window event timeline stays bounded" + ) + } + + private func testCommandJournal() { + let journal = CommandJournal() + let first = journal.begin("windows", detail: [:]) + journal.finish(first, outcome: "ok", detail: ["windowIds": ["window-a"]]) + let second = journal.begin("click", detail: ["elementId": "element-a"]) + journal.annotate(second, detail: ["method": "accessibility"]) + journal.finish(second, outcome: ErrorCode.notInteractable) + let entries = journal.diagnostic() + check( + entries.count == 2 && entries[0]["outcome"] as? String == "ok" && entries[0]["durationMs"] is Int + && entries[1]["outcome"] as? String == ErrorCode.notInteractable, + "the command journal records outcomes and durations" + ) + check( + ((entries[0]["detail"] as? JSONObject)?["windowIds"] as? [String]) == ["window-a"], + "the command journal records listed window identities" + ) + check( + (entries[1]["detail"] as? JSONObject)?["method"] as? String == "accessibility" + && (entries[1]["detail"] as? JSONObject)?["elementId"] as? String == "element-a", + "command annotations merge with request details" + ) + for _ in 0..<(CommandJournal.maximumEntries + 5) { + journal.finish(journal.begin("find", detail: [:]), outcome: "ok") + } + check(journal.diagnostic(limit: 1000).count == CommandJournal.maximumEntries, "the command journal stays bounded") + } + + private func testLoginSessionState() { + let unlocked = LoginSessionState(values: ["kCGSSessionOnConsoleKey": 1, "kCGSessionLoginDoneKey": 1]) + check(unlocked.screenLocked == false && unlocked.unavailableReason == nil, "an unlocked console session is available") + let locked = LoginSessionState(values: [ + "CGSSessionScreenIsLocked": 1, + "kCGSSessionOnConsoleKey": 1, + "kCGSessionLoginDoneKey": 1, + ]) + check(locked.unavailableReason?.contains("screen is locked") == true, "a locked screen explains unavailable automation") + let lockedJSON = locked.json["screenLocked"] as? JSONObject + check(lockedJSON?["value"] as? Bool == true, "the session diagnostic reports the observed lock") + let switched = LoginSessionState(values: ["kCGSSessionOnConsoleKey": 0, "kCGSessionLoginDoneKey": 1]) + check(switched.unavailableReason?.contains("console session") == true, "an off-console session explains unavailable automation") + let missing = LoginSessionState(values: nil) + check( + missing.unavailableReason == nil && missing.json["status"] as? String == "unavailable", + "an unreadable session dictionary is reported as unavailable rather than locked" + ) } private func testHello() { diff --git a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Session.swift b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Session.swift new file mode 100644 index 00000000000..d7b87bbc3f8 --- /dev/null +++ b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Session.swift @@ -0,0 +1,87 @@ +import CoreGraphics +import Foundation + +/// Login-session state for diagnosing automation that cannot reach the interactive desktop. +/// +/// `kCGSSessionOnConsoleKey` and `kCGSessionLoginDoneKey` are the public CGSession.h key values. macOS adds +/// `CGSSessionScreenIsLocked` to the same dictionary only while the screen is locked; it is used here as a +/// diagnostic signal, not as an authorization decision. +struct LoginSessionState { + static let source = "CGSessionCopyCurrentDictionary" + private static let onConsoleKey = "kCGSSessionOnConsoleKey" + private static let loginDoneKey = "kCGSessionLoginDoneKey" + private static let screenLockedKey = "CGSSessionScreenIsLocked" + + let available: Bool + let onConsole: Bool? + let loginDone: Bool? + let screenLocked: Bool? + + static func current() -> LoginSessionState { + LoginSessionState(values: CGSessionCopyCurrentDictionary() as? [String: Any]) + } + + init(values: [String: Any]?) { + guard let values else { + available = false + onConsole = nil + loginDone = nil + screenLocked = nil + return + } + available = true + onConsole = (values[Self.onConsoleKey] as? NSNumber)?.boolValue + loginDone = (values[Self.loginDoneKey] as? NSNumber)?.boolValue + screenLocked = (values[Self.screenLockedKey] as? NSNumber)?.boolValue ?? false + } + + /// Why Accessibility window publication and physical input cannot reach applications, if the session explains it. + var unavailableReason: String? { + if screenLocked == true { + return "The macOS screen is locked, so applications cannot publish accessibility windows or receive physical input. Unlock the login session and retry." + } + if onConsole == false { + return "The macOS login session is not the active console session, so applications cannot publish accessibility windows or receive physical input. Switch to this user's session and retry." + } + return nil + } + + var json: JSONObject { + guard available else { + return [ + "reason": "CoreGraphics did not return a login-session dictionary for the helper.", + "source": Self.source, + "status": "unavailable", + ] + } + var result: JSONObject = [ + "loginDone": field(loginDone, key: Self.loginDoneKey), + "onConsole": field(onConsole, key: Self.onConsoleKey), + "screenLocked": field(screenLocked, key: Self.screenLockedKey), + "source": Self.source, + "status": "available", + ] + if let unavailableReason { + result["unavailableReason"] = unavailableReason + } + return result + } + + private func field(_ value: Bool?, key: String) -> JSONObject { + if let value { + return ["status": "available", "value": value] + } + return ["reason": "The login-session dictionary did not include \(key).", "status": "unavailable"] + } +} + +/// Fails with an explicit session diagnosis when the login session explains an automation failure. +func failIfSessionUnavailable(_ operation: String, data: JSONObject = [:]) throws { + let session = LoginSessionState.current() + guard let reason = session.unavailableReason else { + return + } + var diagnostic = data + diagnostic["session"] = session.json + try fail(ErrorCode.inputUnavailable, "\(operation) failed. \(reason)", data: diagnostic) +} diff --git a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/WindowEvents.swift b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/WindowEvents.swift new file mode 100644 index 00000000000..4a6b58e0e89 --- /dev/null +++ b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/WindowEvents.swift @@ -0,0 +1,518 @@ +import AppKit +import ApplicationServices +import Foundation + +/// Records Accessibility window notifications for leased applications. +/// +/// Observers run on a dedicated run-loop thread so notifications are delivered while the worker executes commands. +/// Events are advisory: queries remain authoritative, but a destroyed notification proves a tracked window closed and +/// recent structural events show that window publication is still settling. +final class WindowEventMonitor: @unchecked Sendable { + static let maximumEvents = 512 + static let diagnosticEventLimit = 48 + + private static let applicationNotifications = [ + kAXWindowCreatedNotification, + kAXFocusedWindowChangedNotification, + kAXMainWindowChangedNotification, + kAXUIElementDestroyedNotification, + kAXWindowMiniaturizedNotification, + kAXWindowDeminiaturizedNotification, + kAXApplicationActivatedNotification, + kAXApplicationDeactivatedNotification, + kAXApplicationHiddenNotification, + kAXApplicationShownNotification, + ] + private static let structuralNotifications: Set = [ + kAXWindowCreatedNotification, + kAXFocusedWindowChangedNotification, + kAXMainWindowChangedNotification, + kAXUIElementDestroyedNotification, + kAXWindowMiniaturizedNotification, + kAXWindowDeminiaturizedNotification, + ] + + struct Event { + let sequence: Int + let uptimeNanoseconds: UInt64 + let processID: pid_t + let notification: String + let elementHash: CFHashCode + let windowID: String? + let descriptor: JSONObject? + } + + private struct TrackedWindow { + let element: AXUIElement + let windowID: String + } + + private let lock = NSLock() + private var events: [Event] = [] + private var nextSequence = 1 + private var observers: [pid_t: AXObserver] = [:] + private var registrationFailures: [pid_t: [String]] = [:] + private var trackedByHash: [CFHashCode: [TrackedWindow]] = [:] + private var watchedElements: [pid_t: [CFHashCode: [AXUIElement]]] = [:] + private var closedWindowIDs: Set = [] + private var lastStructuralEvent: [pid_t: UInt64] = [:] + private var runLoop: CFRunLoop? + private var thread: Thread? + private let started = DispatchSemaphore(value: 0) + + /// Receives `window-closed` notifications for tracked windows; set before any application is observed. + var publishWindowClosed: ((String) -> Void)? + + /// Starts observing an application process. Registration failures are recorded rather than thrown because events + /// only add diagnostics and settling hints; automation must not depend on them. + func observe(processID: pid_t) { + startThreadIfNeeded() + lock.lock() + let existing = observers[processID] + lock.unlock() + guard existing == nil else { + return + } + var created: AXObserver? + let createError = AXObserverCreate(processID, windowEventCallback, &created) + guard createError == .success, let observer = created else { + lock.lock() + registrationFailures[processID] = ["AXObserverCreate: \(axErrorName(createError))"] + lock.unlock() + return + } + let refcon = Unmanaged.passUnretained(self).toOpaque() + let application = AXUIElementCreateApplication(processID) + var failures: [String] = [] + for notification in Self.applicationNotifications { + let error = AXObserverAddNotification(observer, application, notification as CFString, refcon) + if error != .success && error != .notificationAlreadyRegistered { + failures.append("\(notification): \(axErrorName(error))") + } + } + lock.lock() + observers[processID] = observer + registrationFailures[processID] = failures + let loop = runLoop + lock.unlock() + if let loop { + CFRunLoopPerformBlock(loop, CFRunLoopMode.defaultMode.rawValue) { + CFRunLoopAddSource(CFRunLoopGetCurrent(), AXObserverGetRunLoopSource(observer), .defaultMode) + } + CFRunLoopWakeUp(loop) + } + } + + /// Stops observing a process and discards its events and tracked windows. + func stop(processID: pid_t) { + lock.lock() + let observer = observers.removeValue(forKey: processID) + registrationFailures.removeValue(forKey: processID) + watchedElements.removeValue(forKey: processID) + lastStructuralEvent.removeValue(forKey: processID) + events.removeAll { $0.processID == processID } + let loop = runLoop + lock.unlock() + if let observer, let loop { + CFRunLoopPerformBlock(loop, CFRunLoopMode.defaultMode.rawValue) { + CFRunLoopRemoveSource(CFRunLoopGetCurrent(), AXObserverGetRunLoopSource(observer), .defaultMode) + } + CFRunLoopWakeUp(loop) + } + } + + /// Associates a registered window with its helper ID and watches it for destruction. + func track(_ element: AXUIElement, windowID: String, processID: pid_t) { + lock.lock() + let hash = CFHash(element) + trackedByHash[hash, default: []].removeAll { $0.windowID == windowID } + trackedByHash[hash, default: []].append(TrackedWindow(element: element, windowID: windowID)) + closedWindowIDs.remove(windowID) + lock.unlock() + watchDestroyed(element, processID: processID) + } + + func forget(windowID: String) { + lock.lock() + for hash in trackedByHash.keys { + trackedByHash[hash]?.removeAll { $0.windowID == windowID } + } + trackedByHash = trackedByHash.filter { !$0.value.isEmpty } + closedWindowIDs.remove(windowID) + lock.unlock() + } + + /// True only when Accessibility reported the tracked window destroyed. + func isClosed(windowID: String) -> Bool { + lock.lock() + defer { lock.unlock() } + return closedWindowIDs.contains(windowID) + } + + /// True while a structural window event for the process arrived within the quiet interval. + func isSettling(processID: pid_t, quietMilliseconds: UInt64 = 400) -> Bool { + lock.lock() + let last = lastStructuralEvent[processID] + lock.unlock() + guard let last else { + return false + } + return DispatchTime.now().uptimeNanoseconds - last < quietMilliseconds * 1_000_000 + } + + func diagnostic(processID: pid_t, limit: Int = WindowEventMonitor.diagnosticEventLimit) -> JSONObject { + let now = DispatchTime.now().uptimeNanoseconds + lock.lock() + let observing = observers[processID] != nil + let failures = registrationFailures[processID] ?? [] + let recent = events.filter { $0.processID == processID }.suffix(limit) + let total = events.filter { $0.processID == processID }.count + lock.unlock() + var result: JSONObject = [ + "events": recent.map { event in + var value: JSONObject = [ + "ageMs": Int((now - event.uptimeNanoseconds) / 1_000_000), + "element": "ax:\(event.elementHash)", + "notification": event.notification, + "sequence": event.sequence, + ] + if let windowID = event.windowID { + value["windowId"] = windowID + } + if let descriptor = event.descriptor { + value["descriptor"] = descriptor + } + return value + }, + "eventsTruncated": total > recent.count, + "observing": observing, + "source": "AXObserver", + ] + if !failures.isEmpty { + result["registrationFailures"] = failures + } + return result + } + + func record(element: AXUIElement, notification: String, describe: Bool = true) { + var processID: pid_t = 0 + _ = AXUIElementGetPid(element, &processID) + let now = DispatchTime.now().uptimeNanoseconds + // Destroyed elements cannot be queried; describe live elements immediately because they may be transient. + var descriptor: JSONObject? + if describe && Self.describedNotifications.contains(notification) { + descriptor = Self.describe(element) + } else if describe && Self.activationNotifications.contains(notification) { + // Activation changes are attributed to whichever application now owns keyboard focus. + descriptor = ["focusedApplication": focusedApplicationIdentity() ?? ["unavailable": true]] + } + let hash = CFHash(element) + var closedWindowID: String? + lock.lock() + let windowID = trackedByHash[hash]?.first { sameAXElement($0.element, element) }?.windowID + if notification == kAXUIElementDestroyedNotification, let windowID, !closedWindowIDs.contains(windowID) { + closedWindowIDs.insert(windowID) + closedWindowID = windowID + } + events.append(Event( + sequence: nextSequence, + uptimeNanoseconds: now, + processID: processID, + notification: notification, + elementHash: hash, + windowID: windowID, + descriptor: descriptor + )) + nextSequence += 1 + if events.count > Self.maximumEvents { + events.removeFirst(events.count - Self.maximumEvents) + } + if Self.structuralNotifications.contains(notification) { + lastStructuralEvent[processID] = now + } + lock.unlock() + if notification == kAXWindowCreatedNotification { + watchDestroyed(element, processID: processID) + } + if let closedWindowID { + publishWindowClosed?(closedWindowID) + } + } + + private static let activationNotifications: Set = [ + kAXApplicationActivatedNotification, + kAXApplicationDeactivatedNotification, + ] + private static let describedNotifications: Set = [ + kAXWindowCreatedNotification, + kAXFocusedWindowChangedNotification, + kAXMainWindowChangedNotification, + ] + + /// Reads a bounded identity summary so transient elements remain identifiable after they disappear. + static func describe(_ element: AXUIElement) -> JSONObject { + // The timeout applies only to this notification's element object, not to equal elements used by commands. + _ = AXUIElementSetMessagingTimeout(element, 1) + var result: JSONObject = [:] + var errors: JSONObject = [:] + func read(_ attribute: String) -> CFTypeRef? { + var value: CFTypeRef? + let error = AXUIElementCopyAttributeValue(element, attribute as CFString, &value) + if error != .success && error != .noValue && error != .attributeUnsupported { + errors[attribute] = axErrorName(error) + } + return error == .success ? value : nil + } + for (attribute, key) in [ + (kAXRoleAttribute, "role"), + (kAXSubroleAttribute, "subrole"), + (kAXRoleDescriptionAttribute, "roleDescription"), + (kAXIdentifierAttribute, "identifier"), + (kAXTitleAttribute, "title"), + ] { + if let value = axString(read(attribute)) { + result[key] = bounded(value, bytes: 128) + } + } + if let position = axPoint(read(kAXPositionAttribute)), let size = axSize(read(kAXSizeAttribute)) { + let frame = CGRect(origin: position, size: size) + result["frame"] = [ + "height": Double(size.height), + "width": Double(size.width), + "x": Double(position.x), + "y": Double(position.y), + ] + var processID: pid_t = 0 + if AXUIElementGetPid(element, &processID) == .success, let window = coreGraphicsWindow(processID: processID, frame: frame) { + result["coreGraphicsWindow"] = window + } + } + if !errors.isEmpty { + result["errors"] = errors + } + if let parent = axElement(read(kAXParentAttribute)) { + result["parent"] = "ax:\(CFHash(parent))" + } + if let children = read(kAXChildrenAttribute), CFGetTypeID(children) == CFArrayGetTypeID() { + result["childCount"] = CFArrayGetCount((children as! CFArray)) + } + if result.isEmpty { + result["unavailable"] = true + } + return result + } + + private func watchDestroyed(_ element: AXUIElement, processID: pid_t) { + lock.lock() + let hash = CFHash(element) + let alreadyWatched = watchedElements[processID]?[hash]?.contains { sameAXElement($0, element) } == true + guard !alreadyWatched, let observer = observers[processID] else { + lock.unlock() + return + } + watchedElements[processID, default: [:]][hash, default: []].append(element) + lock.unlock() + let refcon = Unmanaged.passUnretained(self).toOpaque() + let error = AXObserverAddNotification(observer, element, kAXUIElementDestroyedNotification as CFString, refcon) + if error != .success && error != .notificationAlreadyRegistered { + lock.lock() + registrationFailures[processID, default: []].append("window \(kAXUIElementDestroyedNotification): \(axErrorName(error))") + lock.unlock() + } + } + + private func startThreadIfNeeded() { + lock.lock() + guard thread == nil else { + lock.unlock() + return + } + let thread = Thread { [weak self] in + // A port keeps the run loop alive while no observer sources are registered. + RunLoop.current.add(Port(), forMode: .default) + self?.lock.lock() + self?.runLoop = CFRunLoopGetCurrent() + self?.lock.unlock() + self?.started.signal() + while true { + RunLoop.current.run(mode: .default, before: .distantFuture) + } + } + thread.name = "furn-desktop-driver-window-events" + self.thread = thread + lock.unlock() + thread.start() + started.wait() + } +} + +/// A bounded journal of recent helper commands on the same monotonic clock as window events. +final class CommandJournal: @unchecked Sendable { + static let maximumEntries = 128 + + private struct Entry { + let sequence: Int + let command: String + let startedNanoseconds: UInt64 + var durationMilliseconds: Int? + var outcome: String + var detail: JSONObject + } + + private let lock = NSLock() + private var entries: [Entry] = [] + private var nextSequence = 1 + + func begin(_ command: String, detail: JSONObject) -> Int { + lock.lock() + defer { lock.unlock() } + let sequence = nextSequence + nextSequence += 1 + entries.append(Entry( + sequence: sequence, + command: command, + startedNanoseconds: DispatchTime.now().uptimeNanoseconds, + outcome: "running", + detail: detail + )) + if entries.count > Self.maximumEntries { + entries.removeFirst(entries.count - Self.maximumEntries) + } + return sequence + } + + func finish(_ sequence: Int, outcome: String, detail: JSONObject = [:]) { + lock.lock() + defer { lock.unlock() } + guard let index = entries.lastIndex(where: { $0.sequence == sequence }) else { + return + } + entries[index].durationMilliseconds = Int((DispatchTime.now().uptimeNanoseconds - entries[index].startedNanoseconds) / 1_000_000) + entries[index].outcome = outcome + entries[index].detail.merge(detail) { _, new in new } + } + + /// Adds diagnostic detail to a running entry, such as how a command satisfied its readiness condition. + func annotate(_ sequence: Int, detail: JSONObject) { + lock.lock() + defer { lock.unlock() } + guard let index = entries.lastIndex(where: { $0.sequence == sequence }) else { + return + } + entries[index].detail.merge(detail) { _, new in new } + } + + func diagnostic(limit: Int = 64) -> [JSONObject] { + let now = DispatchTime.now().uptimeNanoseconds + lock.lock() + defer { lock.unlock() } + return entries.suffix(limit).map { entry in + var value: JSONObject = [ + "ageMs": Int((now - entry.startedNanoseconds) / 1_000_000), + "command": entry.command, + "outcome": entry.outcome, + "sequence": entry.sequence, + ] + if let duration = entry.durationMilliseconds { + value["durationMs"] = duration + } + if !entry.detail.isEmpty { + value["detail"] = entry.detail + } + return value + } + } +} + +private func windowEventCallback( + _ observer: AXObserver, + _ element: AXUIElement, + _ notification: CFString, + _ refcon: UnsafeMutableRawPointer? +) { + guard let refcon else { + return + } + Unmanaged.fromOpaque(refcon).takeUnretainedValue().record( + element: element, + notification: notification as String + ) +} + +/// Identifies a process for diagnostics without depending on run-loop-updated workspace state. +func processIdentity(_ processID: pid_t) -> JSONObject { + var result: JSONObject = ["pid": Int(processID)] + if let application = NSRunningApplication(processIdentifier: processID) { + if let name = application.localizedName { + result["name"] = bounded(name, bytes: 128) + } + if let bundleIdentifier = application.bundleIdentifier { + result["bundleIdentifier"] = bounded(bundleIdentifier, bytes: 256) + } + } else { + var buffer = [CChar](repeating: 0, count: 256) + if proc_name(processID, &buffer, UInt32(buffer.count)) > 0 { + result["name"] = String(cString: buffer) + } + } + return result +} + +/// Reports the application that owns system-wide keyboard focus. +func focusedApplicationIdentity() -> JSONObject? { + // Never set a messaging timeout on the system-wide element: AXUIElementSetMessagingTimeout applies it to every + // Accessibility request in this process. + let systemWide = AXUIElementCreateSystemWide() + var value: CFTypeRef? + guard + AXUIElementCopyAttributeValue(systemWide, kAXFocusedApplicationAttribute as CFString, &value) == .success, + let application = axElement(value) + else { + return nil + } + var processID: pid_t = 0 + guard AXUIElementGetPid(application, &processID) == .success else { + return nil + } + return processIdentity(processID) +} + +/// Describes the WindowServer window whose bounds match an Accessibility frame, including off-screen and non-standard +/// layers, so transient AppKit windows can be identified after they disappear. +func coreGraphicsWindow(processID: pid_t, frame: CGRect) -> JSONObject? { + guard + let descriptions = CGWindowListCopyWindowInfo([.optionAll, .excludeDesktopElements], kCGNullWindowID) + as? [[String: Any]] + else { + return nil + } + let matches = descriptions.filter { description in + guard + (description[kCGWindowOwnerPID as String] as? NSNumber)?.int32Value == processID, + let bounds = description[kCGWindowBounds as String] as? [String: Any], + let windowFrame = CGRect(dictionaryRepresentation: bounds as CFDictionary) + else { + return false + } + return abs(windowFrame.minX - frame.minX) < 1 && abs(windowFrame.minY - frame.minY) < 1 + && abs(windowFrame.width - frame.width) < 1 && abs(windowFrame.height - frame.height) < 1 + } + guard let match = matches.first else { + return ["matched": false] + } + var result: JSONObject = ["matched": true, "matchCount": matches.count] + if let number = match[kCGWindowNumber as String] as? NSNumber { + result["number"] = number.intValue + } + if let layer = match[kCGWindowLayer as String] as? NSNumber { + result["layer"] = layer.intValue + } + if let alpha = match[kCGWindowAlpha as String] as? NSNumber { + result["alpha"] = alpha.doubleValue + } + result["onscreen"] = (match[kCGWindowIsOnscreen as String] as? NSNumber)?.boolValue ?? false + if let name = match[kCGWindowName as String] as? String { + result["name"] = bounded(name, bytes: 128) + } + return result +} diff --git a/packages/agentic/desktop-driver/references/ci-integration.md b/packages/agentic/desktop-driver/references/ci-integration.md index 9b54b17379c..0ead45ab0e1 100644 --- a/packages/agentic/desktop-driver/references/ci-integration.md +++ b/packages/agentic/desktop-driver/references/ci-integration.md @@ -120,7 +120,9 @@ and verifies AppKit's all-controls Tab preference before launching the app. Keep this enabled on interactive development and CI Macs. It is not an input permission, a screen-unlock operation, or the similarly named Accessibility Full Keyboard Access feature. A locked/noninteractive session or missing TCC -authority still prevents physical input, and the hosted `physicalClick` policy +authority still prevents physical input; the macOS helper reports a locked or +switched-out session as `input-unavailable` with a `session` diagnostic, also +shown by `doctor --permissions`. The hosted `physicalClick` policy still causes tests requiring physical clicks to skip explicitly. Keyboard-only CI coverage does not establish physical-click or complete focus qualification. diff --git a/packages/agentic/desktop-driver/references/protocol.md b/packages/agentic/desktop-driver/references/protocol.md index dd7edef9ac1..f0a5600d6ff 100644 --- a/packages/agentic/desktop-driver/references/protocol.md +++ b/packages/agentic/desktop-driver/references/protocol.md @@ -55,7 +55,17 @@ Implemented `furn` extensions: - `GET|POST /session/{id}/furn/story`; - `POST /session/{id}/furn/story/reset`; - `POST /session/{id}/furn/story/args`; -- `GET /session/{id}/furn/tree`. +- `GET /session/{id}/furn/tree`; +- `GET /session/{id}/furn/window-events`, when the `windowEvents` feature is + advertised in `furn:features`. + +`furn/window-events` returns the helper's bounded diagnostic timeline for the +session's application: recent window notifications (created, destroyed, focus, +main-window, minimize, activation) and recent helper commands on the same +monotonic clock, each with an `ageMs`. Live window events carry a descriptor +with role, subrole, title, frame, and the matching WindowServer window so +transient popups and sheets remain identifiable after they close. The log is +evidence for diagnosis only; window and element queries remain authoritative. Navigation, history, cookies, frames, shadow roots, prompts, printing, arbitrary JavaScript execution, browser CSS values, and new-window creation @@ -113,6 +123,9 @@ CSS and XPath are not redefined for non-DOM trees. | Deadline expires | `timeout` | | Capability, property, or operation is unavailable | `unsupported operation` | +An intercepted click names the occluding element's role, name, and ID; an +external occluder names its owning application and process ID. + Error data may include bounded operation names, native diagnostic codes, and artifact IDs. It must not expose unrestricted environment values, native handles, or arbitrary physical paths. diff --git a/packages/agentic/desktop-driver/references/test-integration.md b/packages/agentic/desktop-driver/references/test-integration.md index 57091f22206..4d9c174dead 100644 --- a/packages/agentic/desktop-driver/references/test-integration.md +++ b/packages/agentic/desktop-driver/references/test-integration.md @@ -60,7 +60,10 @@ An external supervisor can share an existing session with an isolated Node worker using `attachDesktopWebdriver({ url, sessionId, capabilities })`. This registers the same custom commands without creating a second session. The supervisor retains ownership and must call `delete()` after the worker -exits, including on assertion failures, timeouts, or crashes. +exits, including on assertion failures, timeouts, or crashes. WebdriverIO's +`attach()` does not apply the defaults `remote()` uses, so the attached browser +sets `waitforInterval` (100 ms) and `waitforTimeout` (5 s) explicitly; without +an interval, `waitUntil` and `waitFor*` stop after their first unsatisfied poll. Storybook's preferred top-level `wdio` functions and named function collections use this pattern with Node's built-in test runner. Each function receives the @@ -179,11 +182,23 @@ artifactsRoot/ tests/ -/ + failure-window-events.json failure.png failure-source.xml failure-tree.json + failure-window-.png + failure-window--source.xml ``` +`captureDesktopFailureEvidence` from `./artifacts` produces the `failure-*` +files for both story plans and Storybook `wdio` callbacks. It reads the window +event log first, when the `windowEvents` feature is available, because tree +traversal is slow enough for transient popups and sheets to change state. It +then captures the current window's screenshot (when `screenshot` is available), +source, and tree, followed by the screenshot and source of up to four other open +windows, and restores the current window. Windows that close during capture are +recorded as evidence errors. + Names are sanitized and confined. Failure evidence is best effort and never replaces the original test error. Trees and screenshots may contain sensitive application information; apply the retention and access policy described in diff --git a/packages/agentic/desktop-driver/src/artifacts/ArtifactManager.ts b/packages/agentic/desktop-driver/src/artifacts/ArtifactManager.ts index 336e5b5cc59..ca37c527491 100644 --- a/packages/agentic/desktop-driver/src/artifacts/ArtifactManager.ts +++ b/packages/agentic/desktop-driver/src/artifacts/ArtifactManager.ts @@ -27,6 +27,10 @@ export class ArtifactManager { return this.write(testDirectory, name, 'json', `${JSON.stringify(tree, null, 2)}\n`, 'tree'); } + writeWindowEvents(testDirectory: string, name: string, log: unknown): DesktopArtifact { + return this.write(testDirectory, name, 'json', `${JSON.stringify(log, null, 2)}\n`, 'window-events'); + } + writeRunResult(result: DesktopStoryRunResult): string { const outputPath = this.resolvePath('run.json'); writeAtomic(outputPath, `${JSON.stringify(result, null, 2)}\n`); diff --git a/packages/agentic/desktop-driver/src/artifacts/failureEvidence.test.ts b/packages/agentic/desktop-driver/src/artifacts/failureEvidence.test.ts new file mode 100644 index 00000000000..328b227f2f7 --- /dev/null +++ b/packages/agentic/desktop-driver/src/artifacts/failureEvidence.test.ts @@ -0,0 +1,93 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import type { DesktopSessionClient } from '../client/DesktopDriverClient.js'; +import { ArtifactManager } from './ArtifactManager.js'; +import { captureDesktopFailureEvidence, maximumSecondaryWindowEvidence } from './failureEvidence.js'; + +type StubOptions = { + closed?: readonly string[]; + handles: readonly string[]; + screenshot?: boolean; + windowEvents?: boolean; +}; + +function createStubSession({ closed = [], handles, screenshot = true, windowEvents = true }: StubOptions) { + const calls: string[] = []; + let current = handles[0]; + const session = { + capabilities: { 'furn:features': { screenshot } }, + getPageSource: async () => { + calls.push(`source:${current}`); + return ``; + }, + getTree: async () => { + calls.push(`tree:${current}`); + return []; + }, + getWindowEvents: async () => { + calls.push('windowEvents'); + return { events: [], observing: true, source: 'test' }; + }, + getWindowHandle: async () => current, + getWindowHandles: async () => [...handles], + recordsWindowEvents: () => windowEvents, + switchToWindow: async (handle: string) => { + if (closed.includes(handle)) { + throw new Error(`Window ${handle} closed.`); + } + calls.push(`switch:${handle}`); + current = handle; + return null; + }, + takeScreenshot: async () => { + calls.push(`screenshot:${current}`); + return Buffer.from('png').toString('base64'); + }, + }; + return { calls, current: () => current, session: session as unknown as DesktopSessionClient }; +} + +describe('captureDesktopFailureEvidence', () => { + let root: string; + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'desktop-driver-evidence-')); + }); + afterEach(() => { + fs.rmSync(root, { force: true, recursive: true }); + }); + + test('captures window events first, then the current and secondary windows, and restores the current window', async () => { + const stub = createStubSession({ closed: ['popup-closed'], handles: ['main', 'popup', 'popup-closed'] }); + const evidence = await captureDesktopFailureEvidence(stub.session, new ArtifactManager(root), 'story-test'); + + expect(stub.calls[0]).toBe('windowEvents'); + expect(evidence.artifacts.map(({ name }) => name)).toEqual([ + 'failure-window-events', + 'failure', + 'failure-source', + 'failure-tree', + 'failure-window-1', + 'failure-window-1-source', + ]); + expect(stub.calls).toContain('source:popup'); + expect(evidence.errors).toEqual([expect.stringContaining('failure-window-2 (popup-closed): Window popup-closed closed.')]); + expect(stub.current()).toBe('main'); + }); + + test('omits unsupported evidence and bounds secondary windows', async () => { + const handles = ['main', ...Array.from({ length: maximumSecondaryWindowEvidence + 2 }, (_, index) => `popup-${index}`)]; + const stub = createStubSession({ handles, screenshot: false, windowEvents: false }); + const evidence = await captureDesktopFailureEvidence(stub.session, new ArtifactManager(root), 'story-test'); + + const names = evidence.artifacts.map(({ name }) => name); + expect(names).not.toContain('failure-window-events'); + expect(names).not.toContain('failure'); + expect(names.filter((name) => name.endsWith('-source'))).toHaveLength(maximumSecondaryWindowEvidence + 1); + expect(evidence.errors).toEqual([ + `window handles: captured ${maximumSecondaryWindowEvidence} of ${maximumSecondaryWindowEvidence + 2} secondary windows.`, + ]); + expect(stub.current()).toBe('main'); + }); +}); diff --git a/packages/agentic/desktop-driver/src/artifacts/failureEvidence.ts b/packages/agentic/desktop-driver/src/artifacts/failureEvidence.ts new file mode 100644 index 00000000000..37258a407a9 --- /dev/null +++ b/packages/agentic/desktop-driver/src/artifacts/failureEvidence.ts @@ -0,0 +1,87 @@ +import type { DesktopArtifact } from '../authoring/results.js'; +import type { DesktopSessionClient } from '../client/DesktopDriverClient.js'; +import type { ArtifactManager } from './ArtifactManager.js'; + +export type DesktopFailureEvidence = { + artifacts: DesktopArtifact[]; + errors: string[]; +}; + +/** Bounds secondary-window evidence so a runaway popup cannot dominate failure capture. */ +export const maximumSecondaryWindowEvidence = 4; + +/** + * Captures failure evidence for a live session. + * + * Window events are read first because tree traversal is slow enough for transient windows to change state. The + * current window is captured next, then each other open window, so popups, sheets, and error overlays are visible + * in the evidence even when the test's current window is the main window. + */ +export async function captureDesktopFailureEvidence( + session: DesktopSessionClient, + artifacts: ArtifactManager, + directory: string, +): Promise { + const captured: DesktopArtifact[] = []; + const errors: string[] = []; + const attempt = async (label: string, capture: () => Promise): Promise => { + try { + captured.push(await capture()); + } catch (error) { + errors.push(`${label}: ${error instanceof Error ? error.message : String(error)}`); + } + }; + const screenshots = supportsScreenshots(session); + + if (session.recordsWindowEvents()) { + await attempt('window events', async () => + artifacts.writeWindowEvents(directory, 'failure-window-events', await session.getWindowEvents()), + ); + } + if (screenshots) { + await attempt('screenshot', async () => artifacts.writeScreenshot(directory, 'failure', await session.takeScreenshot())); + } + await attempt('source', async () => artifacts.writeSource(directory, 'failure-source', await session.getPageSource())); + await attempt('tree', async () => artifacts.writeTree(directory, 'failure-tree', await session.getTree())); + + let current: string; + let handles: string[]; + try { + [current, handles] = await Promise.all([session.getWindowHandle(), session.getWindowHandles()]); + } catch (error) { + errors.push(`window handles: ${error instanceof Error ? error.message : String(error)}`); + return { artifacts: captured, errors }; + } + const secondary = handles.filter((handle) => handle !== current); + if (secondary.length > maximumSecondaryWindowEvidence) { + errors.push(`window handles: captured ${maximumSecondaryWindowEvidence} of ${secondary.length} secondary windows.`); + } + try { + for (const [index, handle] of secondary.slice(0, maximumSecondaryWindowEvidence).entries()) { + const name = `failure-window-${index + 1}`; + try { + await session.switchToWindow(handle); + } catch (error) { + // Transient windows often close while evidence is captured; record the gap and continue. + errors.push(`${name} (${handle}): ${error instanceof Error ? error.message : String(error)}`); + continue; + } + if (screenshots) { + await attempt(`${name} screenshot`, async () => artifacts.writeScreenshot(directory, name, await session.takeScreenshot())); + } + await attempt(`${name} source`, async () => artifacts.writeSource(directory, `${name}-source`, await session.getPageSource())); + } + } finally { + if (secondary.length > 0) { + await session.switchToWindow(current).catch((error: unknown) => { + errors.push(`restore window ${current}: ${error instanceof Error ? error.message : String(error)}`); + }); + } + } + return { artifacts: captured, errors }; +} + +function supportsScreenshots(session: DesktopSessionClient): boolean { + const features = session.capabilities['furn:features']; + return !!features && typeof features === 'object' && (features as Record).screenshot === true; +} diff --git a/packages/agentic/desktop-driver/src/artifacts/index.ts b/packages/agentic/desktop-driver/src/artifacts/index.ts index 44e40a744e3..942a1f970e6 100644 --- a/packages/agentic/desktop-driver/src/artifacts/index.ts +++ b/packages/agentic/desktop-driver/src/artifacts/index.ts @@ -1 +1,3 @@ export { ArtifactManager } from './ArtifactManager.js'; +export { captureDesktopFailureEvidence, maximumSecondaryWindowEvidence } from './failureEvidence.js'; +export type { DesktopFailureEvidence } from './failureEvidence.js'; diff --git a/packages/agentic/desktop-driver/src/authoring/results.ts b/packages/agentic/desktop-driver/src/authoring/results.ts index 9376b04a668..1d15435e6a1 100644 --- a/packages/agentic/desktop-driver/src/authoring/results.ts +++ b/packages/agentic/desktop-driver/src/authoring/results.ts @@ -1,7 +1,7 @@ import type { DesktopEndpoint, DesktopPlatformName } from '../protocol/types.js'; export type DesktopArtifact = { - kind: 'screenshot' | 'source' | 'tree'; + kind: 'screenshot' | 'source' | 'tree' | 'window-events'; name: string; path: string; }; diff --git a/packages/agentic/desktop-driver/src/client/DesktopDriverClient.ts b/packages/agentic/desktop-driver/src/client/DesktopDriverClient.ts index a3eb6856a47..8fc495c067d 100644 --- a/packages/agentic/desktop-driver/src/client/DesktopDriverClient.ts +++ b/packages/agentic/desktop-driver/src/client/DesktopDriverClient.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto'; import { WebDriverError } from '../protocol/errors.js'; import { webElementIdentifier } from '../protocol/constants.js'; -import type { DesktopTreeNode } from '../host/types.js'; +import type { DesktopTreeNode, DesktopWindowEventLog } from '../host/types.js'; import type { NewSessionCapabilities, WebDriverActionSequence, @@ -130,6 +130,16 @@ export class DesktopSessionClient { return this.command('GET', '/furn/tree'); } + getWindowEvents(): Promise { + return this.command('GET', '/furn/window-events'); + } + + /** True when the target advertises a native window-event timeline for diagnostics. */ + recordsWindowEvents(): boolean { + const features = this.capabilities['furn:features']; + return typeof features === 'object' && features !== null && (features as { windowEvents?: unknown }).windowEvents === true; + } + getStoryManifest(): Promise { return this.command('GET', '/furn/manifest'); } diff --git a/packages/agentic/desktop-driver/src/host/types.ts b/packages/agentic/desktop-driver/src/host/types.ts index 8b482646be2..b3693b5e679 100644 --- a/packages/agentic/desktop-driver/src/host/types.ts +++ b/packages/agentic/desktop-driver/src/host/types.ts @@ -20,6 +20,30 @@ export type DesktopHostFeatures = { screenshot: boolean; setWindowRect: boolean; wheel: boolean; + /** The host records a native window-event timeline for diagnostics. */ + windowEvents?: boolean; +}; + +/** One recorded native window notification; `ageMs` is relative to when the log was read. */ +export type DesktopWindowEvent = { + ageMs: number; + element: string; + notification: string; + sequence: number; + windowId?: string; +}; + +/** A bounded, advisory window-event timeline and the current native window publication. */ +export type DesktopWindowEventLog = { + /** Recent helper commands on the same clock as `events`, when the host journals them. */ + commands?: { ageMs: number; command: string; detail?: Record; durationMs?: number; outcome: string; sequence: number }[]; + events: DesktopWindowEvent[]; + eventsTruncated: boolean; + observing: boolean; + publication?: Record; + registrationFailures?: string[]; + source: string; + trackedWindows?: { closed: boolean; element: string; id: string; primary: boolean; title: string }[]; }; export type DesktopHostInfo = { @@ -152,6 +176,7 @@ export interface DesktopHost { captureElement(elementId: string, signal?: AbortSignal): Promise; source(windowId: string, signal?: AbortSignal): Promise; tree(windowId: string, signal?: AbortSignal): Promise; + windowEvents?(lease: ApplicationLease, signal?: AbortSignal): Promise; subscribe?(listener: (event: DesktopHostEvent) => void): () => void; dispose(signal?: AbortSignal): Promise; diff --git a/packages/agentic/desktop-driver/src/hosts/fake/FakeDesktopHost.ts b/packages/agentic/desktop-driver/src/hosts/fake/FakeDesktopHost.ts index 547e7d813da..ce5978c9b11 100644 --- a/packages/agentic/desktop-driver/src/hosts/fake/FakeDesktopHost.ts +++ b/packages/agentic/desktop-driver/src/hosts/fake/FakeDesktopHost.ts @@ -8,6 +8,7 @@ import type { DesktopHostInfo, DesktopTarget, DesktopWindow, + DesktopWindowEventLog, NativeElementSnapshot, NativeImage, NativeActionSequence, @@ -74,6 +75,7 @@ export class FakeDesktopHost implements DesktopHost { private readonly elements = new Map(); private readonly initialElements = new Map(); private readonly listeners = new Set<(event: DesktopHostEvent) => void>(); + private readonly windowEventLog: { at: number; notification: string; sequence: number; windowId: string }[] = []; constructor(options: FakeDesktopHostOptions = {}) { this.endpoint = options.endpoint ?? 'windows'; @@ -180,11 +182,31 @@ export class FakeDesktopHost implements DesktopHost { this.elements.delete(id); } } + this.windowEventLog.push({ at: Date.now(), notification: 'window-closed', sequence: this.windowEventLog.length + 1, windowId }); for (const listener of this.listeners) { listener({ type: 'window-closed', windowId }); } } + async windowEvents(_lease: ApplicationLease): Promise { + if (!this.features.windowEvents) { + throw new HostUnsupportedError('The fake host is not configured to record window events.'); + } + const now = Date.now(); + return { + events: this.windowEventLog.map(({ at, notification, sequence, windowId }) => ({ + ageMs: now - at, + element: `fake:${windowId}`, + notification, + sequence, + windowId, + })), + eventsTruncated: false, + observing: true, + source: 'fake', + }; + } + async activate(windowId: string): Promise { this.requireWindow(windowId); this.actions.push({ type: 'activate', windowId }); diff --git a/packages/agentic/desktop-driver/src/hosts/native/NativeDesktopHost.ts b/packages/agentic/desktop-driver/src/hosts/native/NativeDesktopHost.ts index 69bff06e478..856c191de63 100644 --- a/packages/agentic/desktop-driver/src/hosts/native/NativeDesktopHost.ts +++ b/packages/agentic/desktop-driver/src/hosts/native/NativeDesktopHost.ts @@ -8,6 +8,7 @@ import type { DesktopHostInfo, DesktopTarget, DesktopWindow, + DesktopWindowEventLog, NativeActionSequence, NativeElementSnapshot, NativeImage, @@ -210,6 +211,10 @@ export class NativeDesktopHost implements DesktopHost { return this.request('tree', { windowId }, signal); } + windowEvents(lease: ApplicationLease, signal?: AbortSignal): Promise { + return this.request('windowEvents', { lease }, signal); + } + subscribe(listener: (event: DesktopHostEvent) => void): () => void { this.listeners.add(listener); void this.getProcess().catch(() => undefined); diff --git a/packages/agentic/desktop-driver/src/index.ts b/packages/agentic/desktop-driver/src/index.ts index 6cba01532ea..e99b86d8fcf 100644 --- a/packages/agentic/desktop-driver/src/index.ts +++ b/packages/agentic/desktop-driver/src/index.ts @@ -50,6 +50,8 @@ export type { DesktopTarget, DesktopTreeNode, DesktopWindow, + DesktopWindowEvent, + DesktopWindowEventLog, NativeElementScope, NativeElementSnapshot, NativeImage, diff --git a/packages/agentic/desktop-driver/src/runner/StoryTestRunner.ts b/packages/agentic/desktop-driver/src/runner/StoryTestRunner.ts index 62fecdc5629..afc460a8536 100644 --- a/packages/agentic/desktop-driver/src/runner/StoryTestRunner.ts +++ b/packages/agentic/desktop-driver/src/runner/StoryTestRunner.ts @@ -15,6 +15,7 @@ import type { DesktopStoryTest, } from '../authoring/storyTests.js'; import type { ArtifactManager } from '../artifacts/ArtifactManager.js'; +import { captureDesktopFailureEvidence } from '../artifacts/failureEvidence.js'; import type { DesktopElementClient, DesktopSessionClient } from '../client/DesktopDriverClient.js'; import { WebDriverError } from '../protocol/errors.js'; import { webElementIdentifier } from '../protocol/constants.js'; @@ -198,10 +199,10 @@ async function runTest({ } if (status !== 'passed' && artifacts) { - const captured = await captureFailureArtifacts(session, artifacts, `${item.entry.id}-${item.test.id}`); + const captured = await captureDesktopFailureEvidence(session, artifacts, `${item.entry.id}-${item.test.id}`); testArtifacts.push(...captured.artifacts); - if (captured.error) { - errorMessage = `${errorMessage ?? 'Test failed.'} Evidence capture failed: ${captured.error}`; + if (captured.errors.length > 0) { + errorMessage = `${errorMessage ?? 'Test failed.'} Evidence capture failed: ${captured.errors.join('; ')}`; } } @@ -454,31 +455,6 @@ function classifyError(error: unknown): DesktopTestStatus { return error instanceof DesktopAssertionError ? 'failed' : 'failed'; } -async function captureFailureArtifacts( - session: DesktopSessionClient, - artifacts: ArtifactManager, - testDirectory: string, -): Promise<{ artifacts: DesktopArtifact[]; error?: string }> { - const captured: DesktopArtifact[] = []; - const errors: string[] = []; - try { - captured.push(artifacts.writeScreenshot(testDirectory, 'failure', await session.takeScreenshot())); - } catch (error) { - errors.push(error instanceof Error ? error.message : String(error)); - } - try { - captured.push(artifacts.writeSource(testDirectory, 'failure-source', await session.getPageSource())); - } catch (error) { - errors.push(error instanceof Error ? error.message : String(error)); - } - try { - captured.push(artifacts.writeTree(testDirectory, 'failure-tree', await session.getTree())); - } catch (error) { - errors.push(error instanceof Error ? error.message : String(error)); - } - return { artifacts: captured, ...(errors.length > 0 ? { error: errors.join('; ') } : {}) }; -} - function missingCapabilities( capabilities: Readonly>, required: readonly DesktopStoryCapability[], diff --git a/packages/agentic/desktop-driver/src/server/createDesktopDriverServer.ts b/packages/agentic/desktop-driver/src/server/createDesktopDriverServer.ts index dcb886591bc..a42830a4add 100644 --- a/packages/agentic/desktop-driver/src/server/createDesktopDriverServer.ts +++ b/packages/agentic/desktop-driver/src/server/createDesktopDriverServer.ts @@ -233,6 +233,13 @@ async function route(context: RouteContext, sessions: SessionManager, targets: T ); return buildDesktopTree(snapshots); } + if (method === 'GET' && command[1] === 'window-events' && command.length === 2) { + const windowEvents = session.target.host.windowEvents?.bind(session.target.host); + if (!windowEvents) { + throw new WebDriverError('unsupported operation', `Target "${session.target.id}" does not record native window events.`); + } + return hostCommand(session, 'Reading native window events', (signal) => windowEvents(session.lease, signal)); + } return handleStorybookCommand(method, command.slice(1), body, session, sessions); } @@ -548,7 +555,10 @@ async function handleElementCommand( session.target.host.hitTest(snapshot.windowId, point.x, point.y, signal), ); if (hit && !(await isElementOrDescendant(session, snapshot.id, hit))) { - throw new WebDriverError('element click intercepted', `Element "${record.id}" is obscured by another native element.`); + throw new WebDriverError( + 'element click intercepted', + `Element "${record.id}" is obscured by another native element: ${hit.role} "${hit.name ?? ''}" (${hit.id}).`, + ); } } await hostCommand(session, `Clicking element "${record.id}"`, (signal) => diff --git a/packages/agentic/desktop-driver/src/server/desktopDriver.test.ts b/packages/agentic/desktop-driver/src/server/desktopDriver.test.ts index f7589c7c962..b686bdd1e10 100644 --- a/packages/agentic/desktop-driver/src/server/desktopDriver.test.ts +++ b/packages/agentic/desktop-driver/src/server/desktopDriver.test.ts @@ -299,6 +299,34 @@ describe('Desktop Driver W3C remote end', () => { } }); + test('serves the native window-event timeline only for targets that record it', async () => { + const harness = await createDesktopDriverTestHarness({ endpoint: 'macos', platformName: 'macos', features: { windowEvents: true } }); + try { + const client = createDesktopDriverClient({ url: harness.server.url }); + const session = await client.newSession({ alwaysMatch: { platformName: 'macos', 'furn:target': harness.target.id } }); + expect(session.recordsWindowEvents()).toBe(true); + const [windowId] = await session.getWindowHandles(); + await harness.host.closeWindow(windowId); + const log = await session.getWindowEvents(); + expect(log).toMatchObject({ observing: true, source: 'fake' }); + expect(log.events).toEqual([expect.objectContaining({ notification: 'window-closed', sequence: 1, windowId })]); + await session.delete(); + } finally { + await harness.close(); + } + + const unsupported = await createDesktopDriverTestHarness({ endpoint: 'macos', platformName: 'macos' }); + try { + const client = createDesktopDriverClient({ url: unsupported.server.url }); + const session = await client.newSession({ alwaysMatch: { platformName: 'macos', 'furn:target': unsupported.target.id } }); + expect(session.recordsWindowEvents()).toBe(false); + await expect(session.getWindowEvents()).rejects.toMatchObject({ code: 'unsupported operation' }); + await session.delete(); + } finally { + await unsupported.close(); + } + }); + test('routes Storybook commands and invalidates only preview element references', async () => { const manifest: DesktopStoryManifest = { endpoint: 'windows', diff --git a/packages/agentic/desktop-driver/src/wdio/DesktopWebdriver.test.ts b/packages/agentic/desktop-driver/src/wdio/DesktopWebdriver.test.ts index ff437c137a9..1ee524231a4 100644 --- a/packages/agentic/desktop-driver/src/wdio/DesktopWebdriver.test.ts +++ b/packages/agentic/desktop-driver/src/wdio/DesktopWebdriver.test.ts @@ -41,6 +41,8 @@ describe('sanctioned WebdriverIO API', () => { try { const response = await runContract(harness.server.url, harness.target.id, artifactsRoot); expect(response).toMatchObject({ + attachedWaitOptions: { waitforInterval: 100, waitforTimeout: 5000 }, + attachedWaitPolls: 3, manifestEntries: 1, result: { status: 'passed', diff --git a/packages/agentic/desktop-driver/src/wdio/DesktopWebdriver.ts b/packages/agentic/desktop-driver/src/wdio/DesktopWebdriver.ts index c968cda505b..850a4015dbe 100644 --- a/packages/agentic/desktop-driver/src/wdio/DesktopWebdriver.ts +++ b/packages/agentic/desktop-driver/src/wdio/DesktopWebdriver.ts @@ -128,13 +128,17 @@ export async function connectDesktopWebdriver(options: DesktopWebdriverOptions): return bindDesktopCommands(browser, options.url); } +// WebdriverIO applies these defaults in remote() but not attach(). Without an interval, waitUntil and the waitFor* +// commands stop after their first unsatisfied poll instead of honoring their timeout. +const attachedWaitDefaults = { waitforInterval: 100, waitforTimeout: 5_000 } as const; + /** The caller retains ownership of the existing session and must delete it after the worker exits. */ export async function attachDesktopWebdriver(options: DesktopWebdriverAttachment): Promise { const browser = await attach({ ...connectionOptions(options.url), capabilities: options.capabilities, sessionId: options.sessionId, - options: { logLevel: 'silent' }, + options: { logLevel: 'silent', ...attachedWaitDefaults }, }); return bindDesktopCommands(browser, options.url); } diff --git a/packages/agentic/desktop-driver/src/wdio/wdioRunner.contract.cjs b/packages/agentic/desktop-driver/src/wdio/wdioRunner.contract.cjs index b7b41294426..1379e751b26 100644 --- a/packages/agentic/desktop-driver/src/wdio/wdioRunner.contract.cjs +++ b/packages/agentic/desktop-driver/src/wdio/wdioRunner.contract.cjs @@ -16,6 +16,15 @@ async function main() { sessionId: desktop.browser.sessionId, capabilities: desktop.browser.capabilities, }); + let attachedWaitPolls = 0; + await worker.browser.waitUntil(async () => ++attachedWaitPolls >= 3, { + timeout: 2000, + timeoutMsg: 'Attached waitUntil stopped polling.', + }); + const attachedWaitOptions = { + waitforInterval: worker.browser.options.waitforInterval, + waitforTimeout: worker.browser.options.waitforTimeout, + }; const manifest = await worker.browser.desktopListStories(); await desktop.browser.desktopExpect({ state: 'enabled', @@ -23,7 +32,7 @@ async function main() { value: true, }); const result = await desktop.browser.desktopRunStoryTests({ artifactsRoot }); - process.stdout.write(JSON.stringify({ manifestEntries: manifest.entries.length, result })); + process.stdout.write(JSON.stringify({ attachedWaitOptions, attachedWaitPolls, manifestEntries: manifest.entries.length, result })); } finally { await desktop.delete(); } diff --git a/packages/agentic/storybook-desktop/src/testing/runWdioTests.ts b/packages/agentic/storybook-desktop/src/testing/runWdioTests.ts index 27b7ef5c004..73259b9a660 100644 --- a/packages/agentic/storybook-desktop/src/testing/runWdioTests.ts +++ b/packages/agentic/storybook-desktop/src/testing/runWdioTests.ts @@ -5,9 +5,8 @@ import { pathToFileURL } from 'node:url'; import type { DesktopStoryManifest, DesktopStoryManifestEntry } from '@fluentui-react-native/desktop-driver'; import type { DesktopArtifact } from '@fluentui-react-native/desktop-driver/authoring'; -import { ArtifactManager } from '@fluentui-react-native/desktop-driver/artifacts'; +import { ArtifactManager, captureDesktopFailureEvidence } from '@fluentui-react-native/desktop-driver/artifacts'; import { connectDesktopWebdriver } from '@fluentui-react-native/desktop-driver/wdio'; -import type { DesktopWebdriverSession } from '@fluentui-react-native/desktop-driver/wdio'; import type { DesktopStorybookConfig } from '../config/makeDesktopStorybookConfig.js'; import type { Platforms } from '../config/platforms.js'; @@ -178,7 +177,7 @@ export async function runWdioStoryTests( failure = error; result.error = formatDesktopStorybookError(error); writeDesktopStorybookFailure(`wdio "${displayName}" during ${phase}`, error, options.errorOutput); - const evidence = await captureFailure(desktop, artifacts, artifactId); + const evidence = await captureDesktopFailureEvidence(desktop.session, artifacts, artifactId); result.artifacts = evidence.artifacts; result.evidenceErrors = evidence.errors; for (const evidenceError of result.evidenceErrors) { @@ -255,26 +254,6 @@ export function matchesWdioTest(name: string, pattern?: string): boolean { return pattern === undefined || path.matchesGlob(name, pattern); } -async function captureFailure( - desktop: DesktopWebdriverSession, - artifacts: ArtifactManager, - storyId: string, -): Promise<{ artifacts: DesktopArtifact[]; errors: string[] }> { - const errors: string[] = []; - const captured: DesktopArtifact[] = []; - for (const capture of [ - async () => artifacts.writeSource(storyId, 'failure-source', await desktop.session.getPageSource()), - async () => artifacts.writeTree(storyId, 'failure-tree', await desktop.session.getTree()), - ]) { - try { - captured.push(await capture()); - } catch (error) { - errors.push(errorMessage(error)); - } - } - return { artifacts: captured, errors }; -} - function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/packages/agentic/storybook-desktop/src/testing/wdioRunner.contract.cjs b/packages/agentic/storybook-desktop/src/testing/wdioRunner.contract.cjs index 38b2bb840bf..aa90e3e4fac 100644 --- a/packages/agentic/storybook-desktop/src/testing/wdioRunner.contract.cjs +++ b/packages/agentic/storybook-desktop/src/testing/wdioRunner.contract.cjs @@ -161,7 +161,8 @@ async function main() { throw new Error('Named test failure prevented independent cases from running.'); } const evidencePaths = namedFailureResults.flatMap(({ artifacts }) => (artifacts ?? []).map(({ path }) => path)); - if (evidencePaths.length !== 4 || new Set(evidencePaths).size !== 4) { + // Each failure captures a screenshot, page source, and tree. + if (evidencePaths.length !== 6 || new Set(evidencePaths).size !== 6) { throw new Error('Named failure evidence collided.'); } const filtered = await run('named', { test: '*fresh' }); From a55849a993e5de1e0e0b7e06ee8da13e9fdc729a Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 15 Oct 2026 13:31:54 -0700 Subject: [PATCH 06/15] Clear the React Native macOS focused-input record when focus targets detach React Native macOS routes host focus() through TextInputState, which records any focused View as the focused text input until a TextInput clears it. A View focused through requestFocus() stayed recorded after unmounting, so a later ScrollView touch dispatched blur to its removed tag and Paper raised a RedBox. Focus targets now clear the record without dispatching on blur and genuine detach. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../framework-base-host-focus-record.md | 8 +++ .../src/hooks/focusTarget.test.ts | 30 +++++++++++ .../framework-base/src/hooks/focusTarget.ts | 6 +++ .../src/hooks/hostFocusRecord.native.ts | 20 ++++++++ .../src/hooks/hostFocusRecord.test.ts | 50 +++++++++++++++++++ .../src/hooks/hostFocusRecord.ts | 7 +++ references/agents/focus/macos.md | 6 +++ 7 files changed, 127 insertions(+) create mode 100644 .changeset/framework-base-host-focus-record.md create mode 100644 packages/framework-base/src/hooks/hostFocusRecord.native.ts create mode 100644 packages/framework-base/src/hooks/hostFocusRecord.test.ts create mode 100644 packages/framework-base/src/hooks/hostFocusRecord.ts diff --git a/.changeset/framework-base-host-focus-record.md b/.changeset/framework-base-host-focus-record.md new file mode 100644 index 00000000000..f5fcb9dce7c --- /dev/null +++ b/.changeset/framework-base-host-focus-record.md @@ -0,0 +1,8 @@ +--- +'@fluentui-react-native/framework-base': patch +--- + +Clear React Native macOS's focused-input record when a focus target blurs or +detaches. A View focused through `requestFocus()` previously stayed recorded +after unmounting, so a later ScrollView touch dispatched `blur` to the removed +view and raised a Paper exception. diff --git a/packages/framework-base/src/hooks/focusTarget.test.ts b/packages/framework-base/src/hooks/focusTarget.test.ts index edec8e20c08..92b69b741fd 100644 --- a/packages/framework-base/src/hooks/focusTarget.test.ts +++ b/packages/framework-base/src/hooks/focusTarget.test.ts @@ -1,4 +1,13 @@ import { createFocusTarget, isSelfTargetEvent } from './focusTarget'; +import { releaseHostFocusRecord } from './hostFocusRecord'; + +jest.mock('./hostFocusRecord', () => ({ releaseHostFocusRecord: jest.fn() })); + +const releaseRecord = jest.mocked(releaseHostFocusRecord); + +beforeEach(() => { + releaseRecord.mockClear(); +}); describe('focus target controller', () => { it('distinguishes requests from observed native focus', () => { @@ -72,6 +81,27 @@ describe('focus target controller', () => { expect(target.getSnapshot().focused).toBe(false); }); + it('releases the renderer focus record on blur and genuine detach but not during a handoff', async () => { + const target = createFocusTarget(); + const instance = { focus: jest.fn() }; + const detach = target.attach(instance); + target.onFocus(); + target.onBlur(); + expect(releaseRecord).toHaveBeenCalledWith(instance); + + releaseRecord.mockClear(); + detach(); + const handoffDetach = target.attach(instance); + await Promise.resolve(); + expect(releaseRecord).not.toHaveBeenCalled(); + + handoffDetach(); + expect(releaseRecord).not.toHaveBeenCalled(); + await Promise.resolve(); + expect(releaseRecord).toHaveBeenCalledTimes(1); + expect(releaseRecord).toHaveBeenCalledWith(instance); + }); + it('preserves native focus during a same-commit callback-ref handoff', async () => { const target = createFocusTarget(); const instance = { focus: jest.fn() }; diff --git a/packages/framework-base/src/hooks/focusTarget.ts b/packages/framework-base/src/hooks/focusTarget.ts index a0edf3dbf39..7d9bf6926ee 100644 --- a/packages/framework-base/src/hooks/focusTarget.ts +++ b/packages/framework-base/src/hooks/focusTarget.ts @@ -1,3 +1,5 @@ +import { releaseHostFocusRecord } from './hostFocusRecord'; + export type FocusIntent = 'keyboard' | 'pointer' | 'programmatic' | 'restore' | 'accessibility'; export type FocusRequestStatus = 'requested' | 'confirmed' | 'cancelled' | 'not-mounted' | 'not-focusable' | 'unsupported'; @@ -91,6 +93,7 @@ export function createFocusTarget(): FocusTargetController { if (detached === handoff) { detached = undefined; ++generation; + releaseHostFocusRecord(instance); publish(false, undefined, true); } }); @@ -118,6 +121,9 @@ export function createFocusTarget(): FocusTargetController { }, onBlur: () => { cancelPending(); + if (current) { + releaseHostFocusRecord(current); + } publish(false); }, requestFocus: (intent = 'programmatic') => { diff --git a/packages/framework-base/src/hooks/hostFocusRecord.native.ts b/packages/framework-base/src/hooks/hostFocusRecord.native.ts new file mode 100644 index 00000000000..6df72be48af --- /dev/null +++ b/packages/framework-base/src/hooks/hostFocusRecord.native.ts @@ -0,0 +1,20 @@ +import { TextInput } from 'react-native'; + +type TextInputFocusRecord = { + currentlyFocusedInput?: () => unknown; + // react-native-macos exposes TextInputState.blurInput, which clears the record without dispatching a native command. + onTextInputBlur?: (instance: unknown) => void; +}; + +/** + * React Native macOS routes every host component's focus() through TextInputState, which then records that host as + * the focused text input. Only TextInput clears the record, so a focused View that unmounts stays recorded and the + * next ScrollView touch dispatches blur to its removed native view, which Paper reports as an exception. Clear the + * record without dispatching once the host blurs or detaches. + */ +export function releaseHostFocusRecord(instance: object): void { + const record = (TextInput as unknown as { State?: TextInputFocusRecord }).State; + if (record?.onTextInputBlur && record.currentlyFocusedInput?.() === instance) { + record.onTextInputBlur(instance); + } +} diff --git a/packages/framework-base/src/hooks/hostFocusRecord.test.ts b/packages/framework-base/src/hooks/hostFocusRecord.test.ts new file mode 100644 index 00000000000..afdeec512aa --- /dev/null +++ b/packages/framework-base/src/hooks/hostFocusRecord.test.ts @@ -0,0 +1,50 @@ +import { TextInput } from 'react-native'; + +import { releaseHostFocusRecord } from './hostFocusRecord'; + +type FocusRecord = { currentlyFocusedInput: () => unknown; onTextInputBlur?: (instance: unknown) => void }; + +describe('releaseHostFocusRecord', () => { + const statics = TextInput as unknown as { State?: FocusRecord }; + const original = statics.State; + afterEach(() => { + statics.State = original; + }); + + function useRecord(focused: unknown, withDispatchFreeBlur = true) { + let current = focused; + const onTextInputBlur = jest.fn((instance: unknown) => { + if (current === instance) { + current = null; + } + }); + statics.State = { + currentlyFocusedInput: () => current, + ...(withDispatchFreeBlur ? { onTextInputBlur } : {}), + }; + return { current: () => current, onTextInputBlur }; + } + + it('clears the record for the recorded host without dispatching', () => { + const host = {}; + const record = useRecord(host); + releaseHostFocusRecord(host); + expect(record.onTextInputBlur).toHaveBeenCalledWith(host); + expect(record.current()).toBeNull(); + }); + + it('leaves another host recorded', () => { + const other = {}; + const record = useRecord(other); + releaseHostFocusRecord({}); + expect(record.onTextInputBlur).not.toHaveBeenCalled(); + expect(record.current()).toBe(other); + }); + + it('does nothing when the renderer has no dispatch-free blur', () => { + const host = {}; + const record = useRecord(host, false); + releaseHostFocusRecord(host); + expect(record.current()).toBe(host); + }); +}); diff --git a/packages/framework-base/src/hooks/hostFocusRecord.ts b/packages/framework-base/src/hooks/hostFocusRecord.ts new file mode 100644 index 00000000000..dcc2ced524d --- /dev/null +++ b/packages/framework-base/src/hooks/hostFocusRecord.ts @@ -0,0 +1,7 @@ +/** + * Clears a renderer's record that a host instance owns text-input focus. + * Runtimes without React Native keep no such record. + */ +export function releaseHostFocusRecord(_instance: object): void { + // Intentionally empty outside React Native. +} diff --git a/references/agents/focus/macos.md b/references/agents/focus/macos.md index 7190d0dc320..0f23207d49b 100644 --- a/references/agents/focus/macos.md +++ b/references/agents/focus/macos.md @@ -36,6 +36,12 @@ focus visual should be presented as active. - Keep native view identity scoped to the mounted window/surface. Clear it on detach/recycle and use common React 19 ref composition, not legacy componentRef exposure or global numeric-tag lookup. +- React Native macOS routes every host `focus()` through `TextInputState`, which + then records a View as the focused text input until a TextInput clears it. A + focused View that unmounts stays recorded, and the next ScrollView touch + dispatches `blur` to its removed tag, which Paper raises as a RedBox. Focus + targets clear the record without dispatching on blur and genuine detach + (`framework-base` `hostFocusRecord.native.ts`); route host focus through them. Evidence: C-FOCUS-002. From 0ffb15fa3e411a2f522d0ec01593efe4a18afa6a Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 15 Oct 2026 13:31:54 -0700 Subject: [PATCH 07/15] Record native-core Paper runtime qualification on macOS Separate the modern Callout dismissal status so Paper's per-event delivery cannot reorder it, and record full Paper and Fabric story runs and the cross-story focus finding in the modernization plan. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../native-core/plans/modernization.md | 31 ++++++++++++------- .../components/callout/Callout.stories.tsx | 10 +++++- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/packages/agentic/native-core/plans/modernization.md b/packages/agentic/native-core/plans/modernization.md index 6df7efeb7d5..80d772d30b6 100644 --- a/packages/agentic/native-core/plans/modernization.md +++ b/packages/agentic/native-core/plans/modernization.md @@ -168,16 +168,25 @@ Executed: legacy FocusZone navigation/Tab cases, modern Callout readiness/focus/close/ rearm, and four modern FocusZone command, disabled, live-target, and detached-handle cases. -- Paper (`--paper`) renders 175 stories, and every executed case (legacy - Callout, both legacy FocusZone default cases, modern Callout operations) - fails at its first story-control click: the macOS automation provider - "does not report an enabled state" for Paper views. This is the known - Desktop Driver Paper gap, so Paper runtime remains unqualified. +- Paper first failed every executed case because the Desktop Driver required + `AXEnabled`, which Paper views publish only when disabled. With the driver + treating an absent `AXEnabled` as enabled, `yarn storybook smoke --macos +--paper --mode stories-and-tests` renders 175 stories and runs 35 cases + (33 passed; the 2 skips are Fabric accessibility-projection gaps whose + skip conditions do not distinguish the renderer). This qualifies the Paper + runtime lane for the 11 native cases. +- Full Paper runs exposed a cross-story Framework Base bug, not a native-core + defect: a View focused through `focusTarget.requestFocus()` stayed recorded + in React Native macOS `TextInputState` after unmounting (TabList), and the + legacy Callout story's ScrollView tap then dispatched `blur` to the removed + tag, which Paper raised as a RedBox sheet. Focus targets now clear that record + without dispatching on blur and genuine detach. +- The same Fabric command renders 175 stories and passes the same 35-case set + after the driver and Framework Base changes. - macOS, Windows, and Win32 production bundles pass. Windows must re-run its build and the modern/legacy FocusZone cases for the -dedicated command flag and revoker. Paper runtime cases need Desktop Driver -enabled-state support for Paper views before they can execute. +dedicated command flag and revoker. ### Executed evidence on Windows @@ -269,8 +278,8 @@ tracking still needs full geometry qualification and follow-up; do not infer it from the current frame-summation positioning path. Mac local rect/point stories render, but the full one-logical-unit geometry -matrix, 100-cycle leak test, VoiceOver/field-editor cases, and Paper runtime -lane remain unqualified. The ObjC FocusZone navigation algorithm is retained; +matrix, 100-cycle leak test, and VoiceOver/field-editor cases remain +unqualified; the Paper runtime lane passes the authored native cases. The ObjC FocusZone navigation algorithm is retained; a full Swift port is a separate parity-preserving follow-up. ## Windows-machine validation @@ -322,8 +331,8 @@ Passive observation must not activate a popup to inspect it. ## Remaining gates -- Paper runtime/input qualification; the current automation lane targets - Fabric. Paper compilation alone is insufficient. +- Paper geometry, leak, and assistive-technology qualification beyond the + authored runtime cases. - Win32 capability evidence for modern operations; do not infer either support or impossibility from missing observation infrastructure. - Older React/RN codegen and native build compatibility before changing peer diff --git a/packages/agentic/native-core/src/components/callout/Callout.stories.tsx b/packages/agentic/native-core/src/components/callout/Callout.stories.tsx index 9f2f71cbb5f..24924c94164 100644 --- a/packages/agentic/native-core/src/components/callout/Callout.stories.tsx +++ b/packages/agentic/native-core/src/components/callout/Callout.stories.tsx @@ -17,6 +17,8 @@ function Example({ anchorKind, exerciseCommands = false }: ExampleProps) { const [key, setKey] = React.useState(0); const [status, setStatus] = React.useState('Opening'); const [focus, setFocus] = React.useState('No focus'); + // Dismissal is reported separately because renderers may deliver it before or after an operation settles. + const [dismissal, setDismissal] = React.useState('Open'); const value: CalloutAnchor = anchorKind === 'view' ? { kind: 'view', target: anchor.target } @@ -52,6 +54,9 @@ function Example({ anchorKind, exerciseCommands = false }: ExampleProps) { {focus} + + {dismissal} + value + 1); setOpen(true); setStatus('Opening'); + setDismissal('Open'); }} > Reopen @@ -79,7 +85,7 @@ function Example({ anchorKind, exerciseCommands = false }: ExampleProps) { setStatus('Ready'); if (exerciseCommands && key > 0) void exercise(); }} - onDismissed={({ reason }: CalloutDismissedEvent) => setStatus(`Dismissed: ${reason}`)} + onDismissed={({ reason }: CalloutDismissedEvent) => setDismissal(`Dismissed: ${reason}`)} content={{ style: styles.content, testID: 'modern-callout-content' }} > Native popup content @@ -129,9 +135,11 @@ export const NativeOperations: Story = { await (await browser.$('~modern-callout-reopen')).click(); await expect(await browser.$('~modern-callout-status')).toHaveText('confirmed/confirmed/confirmed'); await expect(await browser.$('~modern-callout-focus-status')).toHaveText('Child focused'); + await expect(await browser.$('~modern-callout-dismissal')).toHaveText('Dismissed: programmatic'); await browser.waitUntil(async () => (await browser.getWindowHandles()).length === 1, { timeout: 5000 }); await (await browser.$('~modern-callout-reopen')).click(); await expect(await browser.$('~modern-callout-status')).toHaveText('confirmed/confirmed/confirmed'); + await expect(await browser.$('~modern-callout-dismissal')).toHaveText('Dismissed: programmatic'); await browser.waitUntil(async () => (await browser.getWindowHandles()).length === 1, { timeout: 5000 }); }, }, From 4d55d39f7d94708bb7c0621a7aeba719b6e695dd Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 15 Oct 2026 13:32:03 -0700 Subject: [PATCH 08/15] Qualify Windows Callout runtime and flat Menu/Popover prerequisites Fix the RNW host crash on every Windows Callout mount: InputLightDismissAction does not support weak references, so the auto_revoke subscription faulted at popup creation. Use explicit tokens, give each mount its own popup, window, and subscriptions, and report a shown presentation's dismissal once. Mirror macOS dismissal and focus behavior on Windows: dismiss on outside pointer input through the owner island's pointer source and a UI-thread message hook, dismiss focused content on Escape, honor legacy dismissBehaviors, and return keyboard input to the owner after a focused presentation closes. Treat owned popups as active while their owner is the foreground window so a FocusZone inside Callout content can enter and navigate, and keep the requested gap when placement flips. Retry a Windows Desktop Driver subtree read when a closing popup island disappears while the window root remains live. Include every native-core story on Windows and add authored cases for light-dismiss/rearm, Escape on both facades, a flat Menu composition, and Windows anchor geometry. Windows passes 18/18 native cases across 170 stories; Win32 passes 7/7 across 152 stories, including legacy Callout popup operations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../desktop-driver-transient-subtree.md | 6 + .changeset/native-core-modern-preview.md | 9 + apps/storybook/AGENTS.md | 4 +- apps/storybook/README.md | 7 +- apps/storybook/storybook.config.mts | 3 - .../native/windows/src/automation.cpp | 16 +- .../desktop-driver/references/protocol.md | 4 + packages/agentic/native-core/README.md | 28 +- .../native-core/plans/modernization.md | 180 ++++++++-- .../components/callout/Callout.stories.tsx | 259 ++++++++++++-- .../src/components/callout/SPEC.md | 15 +- .../src/components/focus-zone/SPEC.md | 3 + .../src/legacy/callout/Callout.stories.tsx | 40 ++- .../NativeCore/components/Callout/Callout.cpp | 321 +++++++++++++----- .../windows/NativeCore/shared/FocusRequest.h | 12 +- 15 files changed, 721 insertions(+), 186 deletions(-) create mode 100644 .changeset/desktop-driver-transient-subtree.md diff --git a/.changeset/desktop-driver-transient-subtree.md b/.changeset/desktop-driver-transient-subtree.md new file mode 100644 index 00000000000..d03660dd2bc --- /dev/null +++ b/.changeset/desktop-driver-transient-subtree.md @@ -0,0 +1,6 @@ +--- +'@fluentui-react-native/desktop-driver': patch +--- + +Retry a Windows accessibility subtree read when a descendant disappears mid-read, such as an owned popup island +closing, while its root remains available. Only an unavailable root reports a stale element. diff --git a/.changeset/native-core-modern-preview.md b/.changeset/native-core-modern-preview.md index 5f0a145e146..60479300f20 100644 --- a/.changeset/native-core-modern-preview.md +++ b/.changeset/native-core-modern-preview.md @@ -12,3 +12,12 @@ registrations without new managed transport. Honor explicit Windows FocusZone destinations without entry-focus redirection and cover live-target refusal, cancellation, and detached command handles in native stories. + +Fix the Windows Callout crash on popup creation caused by an unsupported weak +light-dismiss revoker, and release each mount's popup, window, and +subscriptions on unmount. Windows Callout now dismisses on outside pointer +input and Escape, honors legacy `dismissBehaviors`, returns keyboard input to +the owner after a focused presentation closes, and keeps the requested gap +when placement flips. Windows FocusZone requests inside owned popups are +accepted while the popup's owner is active, enabling flat Menu and Popover +composition. diff --git a/apps/storybook/AGENTS.md b/apps/storybook/AGENTS.md index 9367f308453..ab6f495d0e7 100644 --- a/apps/storybook/AGENTS.md +++ b/apps/storybook/AGENTS.md @@ -129,8 +129,8 @@ arbitrary locations. build and registration, Metro launch, full indexed-story traversal, and ownership-safe cleanup. Use `--mode stories-and-tests` to run the component-authored desktop-e2e plans after the complete traversal. - Keep the Windows story-pattern overrides in `storybook.config.mts`; the - current Accordion and Callout Fabric stories still fail-fast the RNW 0.81 - host during traversal. + current Accordion Fabric stories still fail-fast the RNW 0.81 host during + traversal. Native-core Callout and FocusZone stories run on Windows. - WinAppDriver screenshots are not a reliable capture path for WinAppSDK Composition content. After selecting a story through the Storybook control channel, use the agent host's desktop screenshot tool when visual evidence is required. - Build logs, automation evidence, visual trees, screenshots, and session manifests belong under ignored diff --git a/apps/storybook/README.md b/apps/storybook/README.md index 336aa29d10c..48b7b02549b 100644 --- a/apps/storybook/README.md +++ b/apps/storybook/README.md @@ -181,9 +181,10 @@ exact app process launched by the smoke lifecycle. After traversal, the Windows Fabric lifecycle restarts only that owned app and rewrites its lease before running authored tests against the warm Metro bundle. Logs are written beneath `artifacts/windows/smoke-logs`. -The Accordion and Callout stories remain excluded from the Windows catalog -because the current RNW 0.81 Fabric host still fail-fasts while traversing -them. Win32 continues to exercise Callout through its Paper endpoint. +The Accordion stories remain excluded from the Windows catalog because the +current RNW 0.81 Fabric host still fail-fasts while traversing them. Native-core +Callout and FocusZone stories, legacy and modern, run on Windows; Win32 also +exercises the legacy Callout through its Paper endpoint. Storybook's development bundle intentionally contains separate `pretty-format` and `react-is` versions used by its internal tooling. They are excluded from the duplicate-module enforcement; diff --git a/apps/storybook/storybook.config.mts b/apps/storybook/storybook.config.mts index 346b71d5497..a4b78b9e014 100644 --- a/apps/storybook/storybook.config.mts +++ b/apps/storybook/storybook.config.mts @@ -32,9 +32,6 @@ export default makeDesktopStorybookConfig({ '@fluentui-react-native/native-core', { platformSettings: { - windows: { - storyPatterns: ['src/components/focus-zone/**/*.stories.?(ts|tsx)'], - }, win32: { storyPatterns: ['src/legacy/**/*.stories.?(ts|tsx)'], }, diff --git a/packages/agentic/desktop-driver/native/windows/src/automation.cpp b/packages/agentic/desktop-driver/native/windows/src/automation.cpp index 3c5cb1ac5ee..a3444cef6f6 100644 --- a/packages/agentic/desktop-driver/native/windows/src/automation.cpp +++ b/packages/agentic/desktop-driver/native/windows/src/automation.cpp @@ -653,8 +653,20 @@ std::vector Automation::WalkCachedSubtree(IUIAutomationElement* }; winrt::com_ptr cachedRoot; - const HRESULT result = root->BuildUpdatedCache(CreateCacheRequest(TreeScope_Subtree, controlViewOnly).get(), - cachedRoot.put()); + HRESULT result = root->BuildUpdatedCache(CreateCacheRequest(TreeScope_Subtree, controlViewOnly).get(), + cachedRoot.put()); + // A descendant can disappear while its subtree is cached, for example when an owned popup island closes. Retry + // while the root itself remains available; only an unavailable root is a stale element. + for (int attempt = 0; result == UIA_E_ELEMENTNOTAVAILABLE && attempt < 5; attempt += 1) { + winrt::com_ptr probe; + if (FAILED(root->BuildUpdatedCache(CreateCacheRequest(TreeScope_Element, controlViewOnly).get(), probe.put())) || + !probe) { + break; + } + token.Wait(20); + cachedRoot = nullptr; + result = root->BuildUpdatedCache(CreateCacheRequest(TreeScope_Subtree, controlViewOnly).get(), cachedRoot.put()); + } if (result == UIA_E_ELEMENTNOTAVAILABLE) { Fail(kErrorStaleElement, "The native element is no longer available."); } diff --git a/packages/agentic/desktop-driver/references/protocol.md b/packages/agentic/desktop-driver/references/protocol.md index f0a5600d6ff..973aa84dc6c 100644 --- a/packages/agentic/desktop-driver/references/protocol.md +++ b/packages/agentic/desktop-driver/references/protocol.md @@ -130,6 +130,10 @@ Error data may include bounded operation names, native diagnostic codes, and artifact IDs. It must not expose unrestricted environment values, native handles, or arbitrary physical paths. +On Windows, a lookup or tree read whose root remains live retries briefly when +a descendant disappears mid-read, such as a closing owned popup; only a +detached root reports `stale element reference`. + ## Storybook readiness When a target provides a `StoryOrchestrator`, the consumer validates: diff --git a/packages/agentic/native-core/README.md b/packages/agentic/native-core/README.md index f76576b4ff1..f38d39ed2e1 100644 --- a/packages/agentic/native-core/README.md +++ b/packages/agentic/native-core/README.md @@ -249,17 +249,22 @@ The package prebuild regenerates Windows bindings from the shared spec. Native verification must cover registration, events/commands, refs, unmount cleanup, error propagation, and architecture/platform parity, not only JS mocks. -Callout's executable macOS Fabric story verifies native popup creation, `onShow` and -`onDismiss`, outside-click dismissal, and reopening. Run it together -with FocusZone's keyboard cases from `apps/storybook` using the owned lifecycle: +Callout's executable stories verify native popup creation, `onShow` and +`onDismiss`, outside-click and Escape dismissal, and reopening on macOS, +Windows, and Win32; the modern stories add readiness, descendant focus, +explicit close/rearm, a flat Menu composition (FocusZone entry, arrow +navigation, and trigger restoration), and Windows anchor geometry. Run them +together with FocusZone's keyboard cases from `apps/storybook` using the owned +lifecycle: ```sh STORYBOOK_SMOKE_STORY='native-*' yarn storybook smoke --macos --mode stories-and-tests ``` -This traverses the full catalog before running the selected native tests. -The macOS Desktop Driver currently targets Fabric; a successful Paper build -does not establish Paper runtime or input parity. +Use `--windows` or `--win32` for the Windows endpoints. This traverses the +full catalog before running the selected native tests. +Add `--paper` to run the macOS Paper lane; a successful Paper build alone does +not establish Paper runtime or input parity. ## Modern component previews @@ -292,10 +297,13 @@ placement behavior between the renderer adapters; the existing navigation algorithm is not rewritten merely to change language. Windows source/codegen and the native library build are qualified on RNW 0.81.35. -Initial modern FocusZone command/target-lifetime and legacy navigation cases -pass; Windows Callout runtime and the remaining protocol/geometry cases are -still unqualified. Win32 legacy FocusZone cases pass, while its Callout -popup-operation automation remains unqualified. +Modern FocusZone command/target-lifetime, legacy navigation, legacy and +modern Callout readiness, explicit operations, outside-input and Escape +dismissal, rearm, owner focus return, flat Menu composition, and 100% scale +anchor geometry pass on Windows. Win32 legacy FocusZone and legacy Callout +outside-click/recreate and Escape cases pass. Remaining Windows protocol +races, DPI/RTL/scroll geometry, and macOS execution of the new authored +Callout cases are tracked in the plan. Root/Win32 admission, older-peer builds, popup-family transactions, restoration/Tab policy, text fragments, modal containment, and a real module remain explicit gates. See the repository's `plans/modernization.md` for the diff --git a/packages/agentic/native-core/plans/modernization.md b/packages/agentic/native-core/plans/modernization.md index 80d772d30b6..0ace31a6ec3 100644 --- a/packages/agentic/native-core/plans/modernization.md +++ b/packages/agentic/native-core/plans/modernization.md @@ -65,10 +65,16 @@ Do not raise legacy React/RN peer floors implicitly. lifetime, and legacy navigation qualification. - [x] Win32 legacy FocusZone regression and Callout story rendering; no new managed transport. -- [ ] Remaining Windows FocusZone protocol/inactive-window cases and Callout - runtime/geometry qualification. -- [ ] Win32 legacy Callout popup-operation qualification; its macOS-only - executable case is skipped on Win32. +- [x] Windows Callout runtime (2026-10-15): popup-creation crash fix, + per-mount popup/subscription ownership, outside-input and Escape + dismissal, owner keyboard return, FocusZone requests inside owned + popups, a flat Menu composition, and 100% scale view/rect/point + geometry. Rework FocusZone cases pass on the rebuilt library. +- [ ] Remaining Windows FocusZone native generation/stale-result, + pending-race, and inactive-window cases; Callout DPI/RTL/negative-origin, + collision/flip, scroll/transform, and repeated-cycle resource checks. +- [x] Win32 legacy Callout outside-click/recreate and Escape popup operations + (2026-10-15); modern operations remain unadmitted on Win32. - [ ] Publishing, peer-floor, root-admission decisions. This checklist is updated only with completed work and executed evidence. @@ -186,7 +192,94 @@ Executed: - macOS, Windows, and Win32 production bundles pass. Windows must re-run its build and the modern/legacy FocusZone cases for the -dedicated command flag and revoker. +dedicated command flag and revoker. (Windows did so on 2026-10-15; the +revoker crashed RNW and was replaced. See below.) + +### 2026-10-15 Windows Callout and Menu prerequisites + +Executed on Windows 11 x64 at 100% scale (96 DPI) with Node 24.15.0, RNW +0.81.35, react-native-win32 0.81.9, and the rebuilt Debug x64 +`FRNNativeCore.dll` (zero warnings/errors). + +Root cause and changes: + +- Every Windows Callout mount crashed the RNW host with an access violation in + `FRNNativeCore.dll` (`weak_ref::from_com_ref`): + `InputLightDismissAction` does not support weak references, so the macOS + session's `auto_revoke` subscription faulted at popup creation. The fault + surfaced as the next story failing to render. Subscriptions now use + explicit tokens. +- Each mount owns its popup, window, and subscriptions; unmount hides and + reports a shown presentation once (`host-detached`) and releases them for + both facades. Legacy views no longer leave a visible popup after unmount. + Visibility, readiness, and closure share one dismissal transition. +- `InputLightDismissAction` reports outside input only while the popup island + has focus. Windows now mirrors the macOS local mouse monitor: while shown, a + Callout observes the owner island's `InputPointerSource` and a UI-thread + message hook for other application windows (including the owner's + non-client area). Input in a nested popup does not dismiss its owners. +- Escape in focused popup content dismisses as `native-light-dismiss`, like + AppKit `cancelOperation`. Legacy `dismissBehaviors` are honored. +- Closing a presentation that holds keyboard focus returns input to the owner + island, whose retained focused component regains focus, matching AppKit's + owner-window key restoration. +- FocusZone and Callout focus requests treat an owned popup as active while + its root owner is the foreground window (macOS already walks parent + windows), so a FocusZone inside Callout content can enter and navigate. +- Work-area flips keep the requested gap; local rect/point offsets round to + physical pixels. +- The Windows Desktop Driver retries a UI Automation subtree read when a + descendant (a closing popup island) disappears while the window root + remains available. +- Windows now includes every native-core story; the story override was + removed. New/updated authored cases: legacy outside-click/recreate and + Escape (all hosts), modern light-dismiss/rearm, Escape, separated + lifecycle/operation status labels, Menu composition, and Windows-only + view/rect/point geometry (owned popup content is exposed in the owner + window's accessibility tree only on Windows). + +Executed: + +- `yarn workspace @fluentui-react-native/native-core test` (build, stories, + Node contracts, 30 Jest/type tests, one snapshot), `test:stories`, `lint`, + and `format --check` pass. +- From `apps/storybook`: `yarn storybook prep --windows`, `build --windows`, + `bundle --windows`, and `build-driver --windows` pass. +- Before the fixes, five Windows Event Log `Application Error` records showed + `0xc0000005` at the same `FRNNativeCore.dll` offset; offline + `llvm-symbolizer` mapped it to the revoker's weak reference. None recur. +- `$env:STORYBOOK_SMOKE_STORY = 'native-*'; yarn storybook smoke --windows --mode stories-and-tests` + renders 170 stories (all native-core stories) and passes 18 cases with zero + failures/skips: five legacy FocusZone cases from the components catalog + (directional/spatial, Tab/Shift+Tab exits), four modern FocusZone cases, + two legacy Callout cases, and seven modern Callout cases (light-dismiss/ + rearm, Escape, focus/reposition/close/rearm, Menu composition, and three + geometry cases within one logical unit). No owned listener remained. +- A manual owned-host probe confirmed no leaked popup windows after Callout + teardown, title-bar and owner client clicks dismiss, and a click inside the + popup does not. +- `$env:STORYBOOK_SMOKE_STORY = 'native-*'; yarn storybook smoke --win32 --mode stories-and-tests` + renders 152 stories and passes seven cases with zero failures/skips: the + five legacy FocusZone cases plus the host-provided legacy Callout's + outside-click/recreate and Escape cases. No owned listener remained. +- `yarn workspace @fluentui-react-native/native-core codegen:windows --check` + passes without generated-source changes. +- `yarn workspace @fluentui-react-native/desktop-driver test` passes 58 tests + (three native-gated skips); with `FURN_NATIVE_DRIVER_TEST=1` and + `--runInBand`, the Windows native contract passes 59 tests (two + platform-gated skips). +- `yarn clean-lage-cache` then `yarn lage buildci`: every task passes except + root `lint-repo`, whose knip step reports unused dependencies only in + untouched packages and fails identically on clean `HEAD` on this Windows + machine. One `storybook-desktop` test failed once under the parallel run; + the package's 175 tests pass in isolation and the task passes on a + `--continue` rerun. `yarn change:check` passes. + +Not established: macOS execution of the new/changed authored cases (they are +platform-neutral apart from Windows-only geometry), DPI other than 100%, RTL, +negative monitor origins, collision/flip geometry, scrolled or transformed +anchors (the frame-summing position ignores scroll offsets), app deactivation +for presentations without focus, and repeated-cycle resource measurements. ### Executed evidence on Windows @@ -239,8 +332,9 @@ and consolidated `FRNNativeCore.dll` were rebuilt after the source fix; - Both owned native smoke lifecycles shut down their app/services; their channel, Metro, and driver ports have no remaining listeners. -The Windows Callout story exclusion is unchanged. Native C++ compilation -does not qualify its popup operations, geometry, or resource lifecycle. +The Windows Callout story exclusion was unchanged on 2026-10-02; it was +removed on 2026-10-15 after the crash fix and native Callout qualification +above. ### How to exercise the new API @@ -256,26 +350,39 @@ Callout `onReady` supplies an opaque presentation; its handle exposes `requestFocus(presentation, target)`, `reposition(presentation)`, and `close(presentation)`. Results are not inferred from void commands. -The initial close operation does not perform automatic restoration or family -closure. These policies remain for the Menu/Popover stage. Do not treat a -timeout as evidence that no native mutation occurred. +The initial close operation does not perform policy-driven restoration or +family closure. Closing a focused presentation returns keyboard input to the +owner window's retained focus on both hosts; a Menu still restores its +trigger explicitly after an item closes it. Do not treat a timeout as evidence +that no native mutation occurred. + +A flat nonmodal Menu or Popover composes these primitives: controlled +`open`/`presentationKey`, a view anchor on the trigger, `onReady` followed by +`commandsRef.requestFocus('first' | 'last')` on a FocusZone inside `content`, +FocusZone arrow navigation, `onDismissed` reasons for outside input/Escape/ +host loss, and `close(presentation)` after item invocation. The +`MenuComposition` story is the executable reference on Windows. Win32 Menu +and Popover use the `/legacy` Callout and FocusZone (`setInitialFocus`, +`dismissBehaviors`, `onDismiss`, and the FocusZone `focus()` ref). ### Known remaining implementation/qualification The first foundation/primitive milestone is implemented, not the entire Menu/Popover roadmap. Family transactions, safe restore/owner-relative Tab, -text layout providers, modal isolation, actual Menu/Popover integration, +text layout providers, modal isolation, actual Menu/Popover components, disabled discoverability, and a real module remain. -Windows helpers/handlers and local rect/point placement now compile on RNW -0.81.35. Initial FocusZone focus-result identity, scoped target rejection, -detached handles, and legacy navigation have native evidence. Native -generation mismatch/stale result, pending abort/unmount races, target -replacement, inactive-window refusal, and broader resource checks remain. -Windows Callout runtime behavior remains excluded and unqualified. -Windows ancestor transform/scroll -tracking still needs full geometry qualification and follow-up; do not infer -it from the current frame-summation positioning path. +Windows FocusZone focus-result identity, scoped target rejection, detached +handles, legacy navigation, and FocusZone entry inside an owned popup have +native evidence. Native generation mismatch/stale result, pending +abort/unmount races, target replacement, and inactive-window refusal remain. +Windows Callout readiness, explicit operations, outside-input/Escape +dismissal, rearm, owner focus return, teardown without leaked popups, and +100% scale view/rect/point geometry are qualified. Windows ancestor +transform/scroll tracking still needs follow-up; do not infer it from the +current frame-summation positioning path. DPI, RTL, negative monitor origins, +collision/flip, app deactivation without popup focus, and repeated-cycle +resource checks remain. Mac local rect/point stories render, but the full one-logical-unit geometry matrix, 100-cycle leak test, and VoiceOver/field-editor cases remain @@ -307,19 +414,20 @@ yarn storybook prep --windows yarn storybook build --windows yarn storybook bundle --windows yarn storybook smoke --windows --mode stories -STORYBOOK_SMOKE_STORY='native-modern-focuszone--*' yarn storybook smoke --windows --mode stories-and-tests +STORYBOOK_SMOKE_STORY='native-*' yarn storybook smoke --windows --mode stories-and-tests yarn storybook bundle --win32 -yarn storybook smoke --win32 --mode stories +STORYBOOK_SMOKE_STORY='native-*' yarn storybook smoke --win32 --mode stories-and-tests ``` -The existing Windows Callout story exclusion must not be removed merely to -claim qualification. Inspect the fresh native library/provider and first -actionable failure. Record exact commands, versions, pass/fail/skip counts, -and whether a case actually exercises the new protocol. +Inspect the fresh native library/provider and first actionable failure. A +Windows host crash can surface as the next story failing to render; check +whether the owned host exited before treating it as a render timeout. Record +exact commands, versions, pass/fail/skip counts, and whether a case actually +exercises the new protocol. -Windows currently includes the three modern FocusZone stories, not modern -Callout. Win32 includes only legacy native-core stories; modern previews are -not imported and no new managed fields/commands are dispatched there. +Windows includes every native-core story, legacy and modern. Win32 includes +only legacy native-core stories; modern previews are not imported and no new +managed fields/commands are dispatched there. Required scenarios: command-generation mismatch, stale result, abort/unmount, default/first/last/specific descendant focus, disabled/unfocusable/outside @@ -329,6 +437,20 @@ automatic reopen loop, target replacement, local rect/point placement, alignment/gap/collision, DPI/RTL/negative monitor origins, and teardown. Passive observation must not activate a popup to inspect it. +### macOS rerun required + +The 2026-10-15 Windows session changed authored stories that also run on +macOS: the legacy Callout outside-click case now runs on every host, legacy +`WindowCommands` adds Escape, and modern Callout adds light-dismiss, Escape, +and Menu composition cases. After reconciliation with the macOS Paper work, +modern Callout reports lifecycle (`modern-callout-status`), operation results +(`modern-callout-operations`), and dismissal (`modern-callout-dismissal`) +separately; readiness resets the dismissal label so an automatic reopen of a +latched presentation is observable. Run +`STORYBOOK_SMOKE_STORY='native-*' yarn storybook smoke --macos --mode stories-and-tests` +with and without `--paper`, and record whether Menu composition restores the +trigger through AppKit's owner-window key restoration. + ## Remaining gates - Paper geometry, leak, and assistive-technology qualification beyond the diff --git a/packages/agentic/native-core/src/components/callout/Callout.stories.tsx b/packages/agentic/native-core/src/components/callout/Callout.stories.tsx index 24924c94164..ea8657365a3 100644 --- a/packages/agentic/native-core/src/components/callout/Callout.stories.tsx +++ b/packages/agentic/native-core/src/components/callout/Callout.stories.tsx @@ -6,16 +6,19 @@ import type { WdioStory } from '@fluentui-react-native/storybook-desktop/testing import { Callout } from './Callout'; import type { CalloutAnchor, CalloutHandle, CalloutDismissedEvent } from './Callout.types'; +import { FocusZone } from '../focus-zone/FocusZone'; +import type { FocusZoneCommands } from '../focus-zone/FocusZone.types'; import { useNativeViewTarget } from '../nativeTarget'; -type ExampleProps = { anchorKind: CalloutAnchor['kind']; exerciseCommands?: boolean }; -function Example({ anchorKind, exerciseCommands = false }: ExampleProps) { +type ExampleProps = { anchorKind: CalloutAnchor['kind']; exercise?: 'commands' | 'focus' }; +function Example({ anchorKind, exercise }: ExampleProps) { const anchor = useNativeViewTarget(); const child = useNativeViewTarget(); const handle = React.useRef(null); const [open, setOpen] = React.useState(true); const [key, setKey] = React.useState(0); const [status, setStatus] = React.useState('Opening'); + const [operations, setOperations] = React.useState('Idle'); const [focus, setFocus] = React.useState('No focus'); // Dismissal is reported separately because renderers may deliver it before or after an operation settles. const [dismissal, setDismissal] = React.useState('Open'); @@ -25,32 +28,37 @@ function Example({ anchorKind, exerciseCommands = false }: ExampleProps) { : anchorKind === 'point' ? { kind: 'point', relativeTo: anchor.target, point: { x: 40, y: 60 } } : { kind: 'rect', relativeTo: anchor.target, rect: { x: 40, y: 30, width: 60, height: 30 } }; - const operation = async (name: 'close' | 'focus' | 'reposition') => { + const current = () => { const api = handle.current; const presentation = api?.getPresentation(); if (!api || !presentation) throw new Error('Callout is not ready.'); + return { api, presentation }; + }; + const operation = async (name: 'close' | 'focus' | 'reposition') => { + const { api, presentation } = current(); const outcome = name === 'focus' ? await api.requestFocus(presentation, child.target) : name === 'close' ? await api.close(presentation) : await api.reposition(presentation); - setStatus(`${name}: ${outcome.status}`); + setOperations(`${name}: ${outcome.status}`); }; - const exercise = async () => { - const api = handle.current; - const presentation = api?.getPresentation(); - if (!api || !presentation) throw new Error('Callout is not ready.'); + const runCommands = async () => { + const { api, presentation } = current(); const focused = await api.requestFocus(presentation, child.target); const positioned = await api.reposition(presentation); const closed = await api.close(presentation); - setStatus(`${focused.status}/${positioned.status}/${closed.status}`); + setOperations(`${focused.status}/${positioned.status}/${closed.status}`); }; return ( {status} + + {operations} + {focus} @@ -67,6 +75,8 @@ function Example({ anchorKind, exerciseCommands = false }: ExampleProps) { setOpen(true); setStatus('Opening'); setDismissal('Open'); + setOperations('Idle'); + setFocus('No focus'); }} > Reopen @@ -83,7 +93,11 @@ function Example({ anchorKind, exerciseCommands = false }: ExampleProps) { placement={{ side: 'bottom', align: 'start', gap: 8 }} onReady={() => { setStatus('Ready'); - if (exerciseCommands && key > 0) void exercise(); + // A native reopen of a latched presentation would report readiness again and clear the dismissal label. + setDismissal('Open'); + if (key === 0) return; + if (exercise === 'commands') void runCommands(); + else if (exercise === 'focus') void operation('focus'); }} onDismissed={({ reason }: CalloutDismissedEvent) => setDismissal(`Dismissed: ${reason}`)} content={{ style: styles.content, testID: 'modern-callout-content' }} @@ -118,38 +132,237 @@ function Example({ anchorKind, exerciseCommands = false }: ExampleProps) { ); } + +const menuItems = ['Cut', 'Copy', 'Paste'] as const; + +/** A flat nonmodal menu composed from the primitives: Callout presence, FocusZone entry and arrow navigation. */ +function MenuCompositionExample() { + const trigger = React.useRef>(null); + const anchor = useNativeViewTarget(); + const commands = React.useRef(null); + const [open, setOpen] = React.useState(false); + const [key, setKey] = React.useState(0); + const [status, setStatus] = React.useState('Closed'); + const [focused, setFocused] = React.useState('None'); + return ( + + + {status} + + + {focused} + + { + trigger.current = instance; + const detach = anchor.ref(instance); + return () => { + trigger.current = null; + if (typeof detach === 'function') detach(); + }; + }} + focusable + accessibilityRole="button" + style={styles.button} + testID="modern-menu-trigger" + onPress={() => { + setKey((value) => value + 1); + setOpen(true); + setStatus('Opening'); + setFocused('None'); + }} + > + Edit + + { + setStatus('Ready'); + void commands.current?.requestFocus('first').then(({ status: outcome }) => setStatus(`Ready: ${outcome}`)); + }} + onDismissed={({ reason }: CalloutDismissedEvent) => { + setOpen(false); + setStatus(`Dismissed: ${reason}`); + // A menu returns focus to its trigger; the native close already restored keyboard input to the owner. + if (reason !== 'native-light-dismiss') trigger.current?.focus(); + }} + content={{ style: styles.menu, testID: 'modern-menu-content' }} + > + + {menuItems.map((label) => ( + setFocused(label)} + > + {label} + + ))} + + + + ); +} const meta: Meta = { title: 'Native/Modern Callout', render: (args) => , args: { anchorKind: 'view' } }; export default meta; type Story = WdioStory>; -export const Default: Story = {}; +export const Default: Story = { + tags: ['desktop-e2e'], + wdio: { + 'light-dismisses on an outside owner click and explicitly rearms': async ({ browser, expect, skip }) => { + if (!browser.capabilities['furn:features']?.physicalClick) { + skip('Light dismissal requires physical owner input.'); + return; + } + const status = await browser.$('~modern-callout-status'); + const dismissal = await browser.$('~modern-callout-dismissal'); + for (let attempt = 0; attempt < 2; attempt++) { + await expect(status).toHaveText('Ready'); + await expect(dismissal).toHaveText('Open'); + await status.click(); + await expect(dismissal).toHaveText('Dismissed: native-light-dismiss'); + // A latched presentation must not reopen merely because open remains true; readiness would reset the label. + await browser.pause(500); + await expect(dismissal).toHaveText('Dismissed: native-light-dismiss'); + await (await browser.$('~modern-callout-reopen')).click(); + } + await expect(status).toHaveText('Ready'); + await expect(dismissal).toHaveText('Open'); + }, + 'places content below the view anchor with the requested gap': async ({ browser, expect, platform, skip }) => { + if (platform !== 'windows') { + skip('Only the Windows provider exposes owned popup content in the owner window accessibility tree.'); + return; + } + await expect(await browser.$('~modern-callout-status')).toHaveText('Ready'); + const anchor = await browser.getElementRect(await (await browser.$('~modern-callout-anchor')).elementId); + const content = await browser.getElementRect(await (await browser.$('~modern-callout-content')).elementId); + expect(Math.abs(content.x - anchor.x)).toBeLessThanOrEqual(1); + expect(Math.abs(content.y - (anchor.y + anchor.height + 8))).toBeLessThanOrEqual(1); + }, + }, +}; export const NativeOperations: Story = { - args: { exerciseCommands: true }, + args: { exercise: 'commands' }, tags: ['desktop-e2e'], wdio: { - 'readies, confirms descendant focus, closes, and explicitly rearms': async ({ browser, expect, skip }) => { + 'readies, confirms descendant focus, closes, and explicitly rearms': async ({ browser, expect, platform, skip }) => { if (!browser.capabilities['furn:features']?.physicalClick) { skip('Physical owner input unavailable.'); return; } - await expect(await browser.$('~modern-callout-status')).toHaveText('Ready'); + const status = await browser.$('~modern-callout-status'); + const operations = await browser.$('~modern-callout-operations'); + await expect(status).toHaveText('Ready'); + for (let attempt = 0; attempt < 2; attempt++) { + await (await browser.$('~modern-callout-reopen')).click(); + await expect(operations).toHaveText('confirmed/confirmed/confirmed'); + await expect(await browser.$('~modern-callout-dismissal')).toHaveText('Dismissed: programmatic'); + await expect(await browser.$('~modern-callout-focus-status')).toHaveText('Child focused'); + // Windows popups are owned tool windows that the driver does not enumerate as top-level windows. + if (platform === 'macos') await browser.waitUntil(async () => (await browser.getWindowHandles()).length === 1, { timeout: 5000 }); + } + }, + }, +}; +export const KeyboardDismissal: Story = { + args: { exercise: 'focus' }, + wdio: { + 'dismisses focused content on Escape': async ({ browser, expect, skip }) => { + const features = browser.capabilities['furn:features']; + if (!features?.physicalClick || !features.keyboard) { + skip('Escape dismissal requires physical owner and keyboard input.'); + return; + } + const status = await browser.$('~modern-callout-status'); + await expect(status).toHaveText('Ready'); await (await browser.$('~modern-callout-reopen')).click(); - await expect(await browser.$('~modern-callout-status')).toHaveText('confirmed/confirmed/confirmed'); + await expect(await browser.$('~modern-callout-operations')).toHaveText('focus: confirmed'); await expect(await browser.$('~modern-callout-focus-status')).toHaveText('Child focused'); - await expect(await browser.$('~modern-callout-dismissal')).toHaveText('Dismissed: programmatic'); - await browser.waitUntil(async () => (await browser.getWindowHandles()).length === 1, { timeout: 5000 }); - await (await browser.$('~modern-callout-reopen')).click(); - await expect(await browser.$('~modern-callout-status')).toHaveText('confirmed/confirmed/confirmed'); - await expect(await browser.$('~modern-callout-dismissal')).toHaveText('Dismissed: programmatic'); - await browser.waitUntil(async () => (await browser.getWindowHandles()).length === 1, { timeout: 5000 }); + const dismissal = await browser.$('~modern-callout-dismissal'); + await expect(dismissal).toHaveText('Open'); + await browser.keys('\uE00C'); + await expect(dismissal).toHaveText('Dismissed: native-light-dismiss'); + }, + }, +}; +export const LocalRectangle: Story = { + args: { anchorKind: 'rect' }, + wdio: { + 'places content below the local rectangle with the requested gap': async ({ browser, expect, platform, skip }) => { + if (platform !== 'windows') { + skip('Only the Windows provider exposes owned popup content in the owner window accessibility tree.'); + return; + } + await expect(await browser.$('~modern-callout-status')).toHaveText('Ready'); + const anchor = await browser.getElementRect(await (await browser.$('~modern-callout-anchor')).elementId); + const content = await browser.getElementRect(await (await browser.$('~modern-callout-content')).elementId); + expect(Math.abs(content.x - (anchor.x + 40))).toBeLessThanOrEqual(1); + expect(Math.abs(content.y - (anchor.y + 30 + 30 + 8))).toBeLessThanOrEqual(1); + }, + }, +}; +export const LocalPoint: Story = { + args: { anchorKind: 'point' }, + wdio: { + 'places content below the local point with the requested gap': async ({ browser, expect, platform, skip }) => { + if (platform !== 'windows') { + skip('Only the Windows provider exposes owned popup content in the owner window accessibility tree.'); + return; + } + await expect(await browser.$('~modern-callout-status')).toHaveText('Ready'); + const anchor = await browser.getElementRect(await (await browser.$('~modern-callout-anchor')).elementId); + const content = await browser.getElementRect(await (await browser.$('~modern-callout-content')).elementId); + expect(Math.abs(content.x - (anchor.x + 40))).toBeLessThanOrEqual(1); + expect(Math.abs(content.y - (anchor.y + 60 + 8))).toBeLessThanOrEqual(1); + }, + }, +}; +export const MenuComposition: Story = { + render: () => , + tags: ['desktop-e2e'], + wdio: { + 'enters a FocusZone, navigates by arrow, and restores the trigger on Escape': async ({ browser, expect, skip }) => { + const features = browser.capabilities['furn:features']; + if (!features?.physicalClick || !features.keyboard) { + skip('Menu composition requires physical owner and keyboard input.'); + return; + } + const trigger = await browser.$('~modern-menu-trigger'); + const status = await browser.$('~modern-menu-status'); + const focused = await browser.$('~modern-menu-focus'); + for (let attempt = 0; attempt < 2; attempt++) { + await trigger.click(); + await expect(status).toHaveText('Ready: confirmed'); + await expect(focused).toHaveText('Cut'); + await browser.keys('\uE015'); + await expect(focused).toHaveText('Copy'); + await browser.keys('\uE013'); + await browser.keys('\uE013'); + await expect(focused).toHaveText('Paste'); + await browser.keys('\uE00C'); + await expect(status).toHaveText('Dismissed: native-light-dismiss'); + await browser.waitUntil(async () => (await trigger.getProperty('focused')) === true, { + timeout: 5000, + timeoutMsg: 'Expected keyboard focus to return to the menu trigger.', + }); + } }, }, }; -export const LocalRectangle: Story = { args: { anchorKind: 'rect' } }; -export const LocalPoint: Story = { args: { anchorKind: 'point' } }; const styles = StyleSheet.create({ story: { padding: 24, gap: 16 }, row: { flexDirection: 'row', gap: 8 }, button: { padding: 10, borderWidth: 1, borderColor: '#808080' }, anchor: { width: 280, height: 100, borderWidth: 1, borderColor: '#808080', padding: 10 }, content: { width: 320, padding: 16, gap: 12, backgroundColor: '#ffffff' }, + menu: { width: 200, paddingVertical: 4, backgroundColor: '#ffffff' }, + menuItem: { paddingHorizontal: 12, paddingVertical: 8 }, }); diff --git a/packages/agentic/native-core/src/components/callout/SPEC.md b/packages/agentic/native-core/src/components/callout/SPEC.md index 1039e867b47..cf2a5e68f1f 100644 --- a/packages/agentic/native-core/src/components/callout/SPEC.md +++ b/packages/agentic/native-core/src/components/callout/SPEC.md @@ -32,6 +32,15 @@ unmount, invalid generation and timeout produce explicit outcomes. Timeout is unconfirmed; cancellation cannot undo an already-executed native mutation. Focus remains scoped to eligible content and active owned windows. -Request-only veto, precise light-dismiss classification, automatic restoration, -popup-family transactions, modal containment and text fragments are deferred; -no success-shaped substitutes are provided. +Pointer input outside the popup family, Escape in focused content, and losing +the host report `native-light-dismiss` or `host-detached`. Input in a nested +popup does not dismiss the popups that own it. Closing a presentation that +holds keyboard focus returns input to the owner window's retained focused +component; outside input that already moved focus is not overridden. On +Windows, outside input is observed for the owner island and other application +windows; application deactivation dismisses only presentations that hold +focus. A Menu still restores its trigger explicitly after an item closes it. + +Request-only veto, precise light-dismiss classification, policy-driven +restoration, popup-family transactions, modal containment and text fragments +are deferred; no success-shaped substitutes are provided. diff --git a/packages/agentic/native-core/src/components/focus-zone/SPEC.md b/packages/agentic/native-core/src/components/focus-zone/SPEC.md index 5340c5d47f9..6ba58fb6fb9 100644 --- a/packages/agentic/native-core/src/components/focus-zone/SPEC.md +++ b/packages/agentic/native-core/src/components/focus-zone/SPEC.md @@ -26,6 +26,9 @@ void focus return. Entry preferences must not override an explicit command destination. Requests are generation-fenced, cancellable, bounded by an explicit timeout, and invalidated on component/target replacement. Missing mounts are not queued; inactive windows are not reactivated. +A zone inside an owned popup, such as Callout content, is active while the +popup's owner chain includes the active window, so a Menu can enter and move +within its list on both hosts. Cancellation cannot reverse a mutation already executed natively. Disabled discoverability, remembered focus, natural cross-popup Tab diff --git a/packages/agentic/native-core/src/legacy/callout/Callout.stories.tsx b/packages/agentic/native-core/src/legacy/callout/Callout.stories.tsx index c139f7a9fb6..2a4d0b6e8bd 100644 --- a/packages/agentic/native-core/src/legacy/callout/Callout.stories.tsx +++ b/packages/agentic/native-core/src/legacy/callout/Callout.stories.tsx @@ -186,25 +186,25 @@ export const Default: Story = { tags: ['desktop-e2e'], render: (args) => , wdio: { - 'macOS opens, dismisses, and recreates a native popup window': async ({ browser, expect, platform, skip }) => { - if (platform !== 'macos') { - skip('This case verifies the AppKit popup-window lifecycle.'); - return; - } + 'opens, light-dismisses, and recreates a native popup': async ({ browser, expect, platform, skip }) => { const features = browser.capabilities['furn:features']; if (!features?.physicalClick) { skip('Popup lifecycle qualification requires physical pointer input.'); return; } + const status = await browser.$('~agentic-storybook-callout-status'); for (let attempt = 0; attempt < 2; attempt++) { - await expect(await browser.$('~agentic-storybook-callout-status')).toHaveText('Native window: Shown'); - expect(await browser.getWindowHandles()).toHaveLength(2); - await (await browser.$('~agentic-storybook-callout-status')).click(); - await browser.waitUntil(async () => (await browser.getWindowHandles()).length === 1, { - timeout: 5000, - timeoutMsg: 'Expected dismissal to remove the native Callout window.', - }); - await expect(await browser.$('~agentic-storybook-callout-status')).toHaveText('Native window: Dismissed'); + await expect(status).toHaveText('Native window: Shown'); + // Only the AppKit provider enumerates the popup as a top-level window. + if (platform === 'macos') expect(await browser.getWindowHandles()).toHaveLength(2); + await status.click(); + if (platform === 'macos') { + await browser.waitUntil(async () => (await browser.getWindowHandles()).length === 1, { + timeout: 5000, + timeoutMsg: 'Expected dismissal to remove the native Callout window.', + }); + } + await expect(status).toHaveText('Native window: Dismissed'); if (attempt === 0) { await (await browser.$('~agentic-storybook-callout-trigger')).click(); } @@ -229,6 +229,20 @@ export const WindowCommands: Story = { setInitialFocus: true, }, render: (args) => , + tags: ['desktop-e2e'], + wdio: { + 'dismisses initially focused content on Escape': async ({ browser, expect, skip }) => { + const features = browser.capabilities['furn:features']; + if (!features?.physicalClick || !features.keyboard) { + skip('Escape dismissal requires an interactive desktop with keyboard input.'); + return; + } + const status = await browser.$('~agentic-storybook-callout-status'); + await expect(status).toHaveText('Native window: Shown'); + await browser.keys('\uE00C'); + await expect(status).toHaveText('Native window: Dismissed'); + }, + }, parameters: { docs: { description: { diff --git a/packages/agentic/native-core/windows/NativeCore/components/Callout/Callout.cpp b/packages/agentic/native-core/windows/NativeCore/components/Callout/Callout.cpp index d853b7a94dd..1ac492e4f0b 100644 --- a/packages/agentic/native-core/windows/NativeCore/components/Callout/Callout.cpp +++ b/packages/agentic/native-core/windows/NativeCore/components/Callout/Callout.cpp @@ -11,8 +11,101 @@ #include #include +#include +#include +#include + namespace winrt::FluentUI::Callout { +// Reports pointer-down input outside a shown popup's owner family, like the macOS local mouse monitor. +// InputLightDismissAction only reports outside input while the popup island has focus. A UI-thread message hook +// exists only while a Callout on that thread is shown, and each Callout registers only while it is shown. +class OutsidePointerMonitor { + public: + static uint64_t Add(HWND popup, std::function onOutside) noexcept { + auto &state = State(); + const auto id = ++state.nextId; + state.entries.push_back({id, popup, std::move(onOutside)}); + if (!state.hook) state.hook = ::SetWindowsHookExW(WH_GETMESSAGE, &HookProc, nullptr, ::GetCurrentThreadId()); + return id; + } + + static void Remove(uint64_t id) noexcept { + if (!id) return; + auto &state = State(); + state.entries.erase( + std::remove_if(state.entries.begin(), state.entries.end(), [id](const Entry &entry) { return entry.id == id; }), + state.entries.end()); + if (state.entries.empty() && state.hook) { + ::UnhookWindowsHookEx(state.hook); + state.hook = nullptr; + } + } + + private: + struct Entry { + uint64_t id; + HWND popup; + std::function onOutside; + }; + struct ThreadState { + HHOOK hook{nullptr}; + uint64_t nextId{0}; + std::vector entries; + }; + + static ThreadState &State() noexcept { + thread_local ThreadState state; + return state; + } + + static bool IsPointerDown(UINT message) noexcept { + switch (message) { + case WM_LBUTTONDOWN: + case WM_RBUTTONDOWN: + case WM_MBUTTONDOWN: + case WM_XBUTTONDOWN: + case WM_NCLBUTTONDOWN: + case WM_NCRBUTTONDOWN: + case WM_NCMBUTTONDOWN: + case WM_NCXBUTTONDOWN: + case WM_POINTERDOWN: + case WM_NCPOINTERDOWN: + return true; + default: + return false; + } + } + + // A popup family is the chain of owned popups below the unowned application window, so input in a nested popup + // does not dismiss the popups that own it. Input in an owning popup's island still dismisses its nested popups. + static bool InFamily(HWND popup, HWND window) noexcept { + HWND familyRoot = popup; + for (HWND owner = ::GetWindow(familyRoot, GW_OWNER); owner && ::GetWindow(owner, GW_OWNER); + owner = ::GetWindow(owner, GW_OWNER)) { + familyRoot = owner; + } + for (HWND current = ::GetAncestor(window, GA_ROOT); current; current = ::GetWindow(current, GW_OWNER)) { + if (current == familyRoot) return true; + } + return false; + } + + static LRESULT CALLBACK HookProc(int code, WPARAM wParam, LPARAM lParam) noexcept { + if (code == HC_ACTION && wParam == PM_REMOVE) { + const auto &message = *reinterpret_cast(lParam); + if (message.hwnd && IsPointerDown(message.message)) { + std::vector> outside; + for (const auto &entry : State().entries) { + if (!InFamily(entry.popup, message.hwnd)) outside.push_back(entry.onOutside); + } + for (const auto &onOutside : outside) onOutside(); + } + } + return ::CallNextHookEx(nullptr, code, wParam, lParam); + } +}; + enum class DirectionalHint { LeftTopEdge, LeftCenter, @@ -68,32 +161,7 @@ struct CalloutComponentView : public winrt::implements, Codegen::BaseCallout { - ~CalloutComponentView() { - if (m_popup && !m_popup.IsClosed()) { - /* - // Unregister closing event handler - if (m_appWindowClosingToken) - { - m_rnWindow.AppWindow().Closing(m_appWindowClosingToken); - m_appWindowClosingToken = {}; - } - */ - - // Hide popup - if (m_popup.IsVisible()) { - m_popup.Hide(); - } - - if (m_rnWindow.AppWindow()) { - m_rnWindow.Close(); - m_rnWindow = nullptr; - } - - // Close bridge - m_popup.Close(); - m_popup = nullptr; - } - } + ~CalloutComponentView() { ReleasePopup(); } void UpdateProps(const winrt::Microsoft::ReactNative::ComponentView &view, const winrt::com_ptr &newProps, @@ -109,13 +177,6 @@ struct CalloutComponentView newProps->directionalHint ? parseDirectionHint(newProps->directionalHint.value()) : DirectionalHint::LeftTopEdge; - - auto portal = view.as< - winrt::Microsoft::ReactNative::Composition::PortalComponentView>(); - if (portal.ContentRoot().Children().Size() != 0) { - AdjustWindowSize( - portal.ContentRoot().Children().GetAt(0).LayoutMetrics()); - } } __super::UpdateProps(view, newProps, oldProps); @@ -137,6 +198,18 @@ struct CalloutComponentView [](const auto & /*sender*/, const auto &view) { view.UserData().as()->OnUnmounted(view); }); + // Escape bubbles from focused popup content to the portal root; macOS dismisses through cancelOperation. + portalComponentView.ContentRoot().KeyDown( + [wkThis = get_weak()]( + const winrt::Windows::Foundation::IInspectable & /*sender*/, + const winrt::Microsoft::ReactNative::Composition::Input::KeyRoutedEventArgs &args) noexcept { + if (args.Handled() || args.Key() != winrt::Windows::System::VirtualKey::Escape) return; + if (auto strongThis = wkThis.get()) { + if (!strongThis->m_shown || strongThis->PreventsDismiss("preventDismissOnKeyDown")) return; + args.Handled(true); + strongThis->DismissPresentation("native-light-dismiss"); + } + }); } void HandleFocusWindowCommand() noexcept override { @@ -151,32 +224,25 @@ struct CalloutComponentView void HandleRequestFocusCommand(int32_t generation, int32_t requestId, int32_t targetTag) noexcept override { auto status = OperationStatus(generation); + auto portal = m_portal.get(); + if (status.empty() && !portal) status = "not-mounted"; if (status.empty()) { - auto portal = m_portal.get(); - auto root = portal.ContentRoot(); - auto target = ::FRNNativeCore::FindDescendant(root, targetTag); - if (!target) status = "not-mounted"; - else if (!::FRNNativeCore::Eligible(target)) status = "not-focusable"; - else { - auto foreground = ::GetForegroundWindow(); - auto popupHwnd = winrt::Microsoft::UI::GetWindowFromWindowId(m_rnWindow.AppWindow().Id()); - if (foreground != ::GetAncestor(m_parentHwnd, GA_ROOT) && foreground != popupHwnd) status = "inactive-window"; - else if (!winrt::Microsoft::UI::Input::InputFocusController::GetForIsland(m_rnWindow.ReactNativeIsland().Island()).TrySetFocus()) status = "refused"; - else status = ::FRNNativeCore::FocusWithin(root, targetTag, "target", 0, m_parentHwnd); - } + // Focusing a component also moves keyboard focus into the popup island. + status = ::FRNNativeCore::FocusWithin(portal.ContentRoot(), targetTag, "target"); } EmitResult(generation, requestId, status); } void HandleCloseCommand(int32_t generation, int32_t requestId) noexcept override { auto status = OperationStatus(generation); + if (status.empty() && !m_shown) status = "not-ready"; if (!status.empty()) { EmitResult(generation, requestId, status); return; } - ClosePresentation("programmatic", requestId); + DismissPresentation("programmatic", requestId); } void HandleRepositionCommand(int32_t generation, int32_t requestId) noexcept override { auto status = OperationStatus(generation); if (status.empty()) { auto portal = m_portal.get(); - if (portal.ContentRoot().Children().Size()) AdjustWindowSize(portal.ContentRoot().Children().GetAt(0).LayoutMetrics()); + if (portal && portal.ContentRoot().Children().Size()) AdjustWindowSize(portal.ContentRoot().Children().GetAt(0).LayoutMetrics()); status = m_positioned ? "confirmed" : "not-ready"; } EmitResult(generation, requestId, status); @@ -212,7 +278,7 @@ struct CalloutComponentView // assert(args.Index() == 0); // assert(m_childLayoutMetricsToken); args.Child().LayoutMetricsChanged(m_childLayoutMetricsToken); - // m_childLayoutMetricsToken.value = 0; + m_childLayoutMetricsToken = {}; } private: @@ -225,14 +291,28 @@ struct CalloutComponentView void EmitResult(int32_t generation, int32_t requestId, const std::string &status) noexcept { if (auto emitter = EventEmitter()) emitter->onOperationResult({generation, requestId, status}); } - void ClosePresentation(const std::string &reason, int32_t requestId = 0) noexcept { - if (m_closed) return; - m_closed = true; - m_ready = false; + // Hides a shown presentation and reports that transition once. Modern views also latch their generation closed. + void DismissPresentation(const std::string &reason, int32_t requestId = 0) noexcept { + if (!m_shown) return; + m_shown = false; + StopWatchingOutsidePointer(); + const bool hadFocus = m_rnWindow && + winrt::Microsoft::UI::Input::InputFocusController::GetForIsland(m_rnWindow.ReactNativeIsland().Island()).HasFocus(); if (m_popup && m_popup.IsVisible()) m_popup.Hide(); - if (requestId) EmitResult(Props()->commandGeneration.value_or(0), requestId, "confirmed"); + // Like AppKit's owner-window key restoration, return keyboard focus to the owner island so its retained focused + // component regains input. Input that already moved elsewhere is not overridden. + if (hadFocus && m_ownerIsland) { + winrt::Microsoft::UI::Input::InputFocusController::GetForIsland(m_ownerIsland).TrySetFocus(); + } + const auto generation = Props() ? Props()->commandGeneration.value_or(0) : 0; + const bool modern = Modern(); + if (modern) { + m_closed = true; + m_ready = false; + } + if (requestId) EmitResult(generation, requestId, "confirmed"); if (auto emitter = EventEmitter()) { - if (Modern()) emitter->onClosed({Props()->commandGeneration.value_or(0), reason}); + if (modern) emitter->onClosed({generation, reason}); emitter->onDismiss({}); } } @@ -265,32 +345,25 @@ struct CalloutComponentView void OnUnmounted( const winrt::Microsoft::ReactNative::ComponentView & /*view*/) noexcept { assert(m_mounted); - if (Modern()) ClosePresentation("host-detached"); + // A detached host cannot keep a visible popup. Each mount owns a fresh popup, window, and dismissal subscription. + DismissPresentation("host-detached"); m_mounted = false; ++m_lifetime; - if (Modern()) { - // Modern presentations own their popup per mount; legacy views keep the popup and its subscription. - m_lightDismissRevoker.revoke(); - if (m_rnWindow) m_rnWindow.Close(); - if (m_popup && !m_popup.IsClosed()) m_popup.Close(); - m_rnWindow = nullptr; - m_popup = nullptr; - } + ReleasePopup(); } void OnLightDismissDismissed( const winrt::Microsoft::UI::Input::InputLightDismissAction &, const winrt::Microsoft::UI::Input::InputLightDismissEventArgs &) { - HidePopup(); + if (PreventsDismiss("preventDismissOnClickOutside")) return; + DismissPresentation("native-light-dismiss"); } - void HidePopup() { - if (Modern()) { ClosePresentation("native-light-dismiss"); return; } - if (!m_popup) - return; - m_popup.Hide(); - if (auto eventEmitter = EventEmitter()) - eventEmitter->onDismiss({}); + bool PreventsDismiss(std::string_view behavior) const noexcept { + const auto &props = Props(); + if (!props || !props->dismissBehaviors) return false; + const auto &behaviors = *props->dismissBehaviors; + return std::find(behaviors.begin(), behaviors.end(), behavior) != behaviors.end(); } void CreatePopup( @@ -300,13 +373,12 @@ struct CalloutComponentView auto portal = view.as< winrt::Microsoft::ReactNative::Composition::PortalComponentView>(); - m_parentHwnd = view.as<::Microsoft::ReactNative::Composition::Experimental::IComponentViewInterop>()->GetHwndForParenting(); - m_popup = winrt::Microsoft::UI::Content::DesktopPopupSiteBridge::Create( - portal.Parent() - .as() - .Root() - .ReactNativeIsland() - .Island()); + m_ownerIsland = portal.Parent() + .as() + .Root() + .ReactNativeIsland() + .Island(); + m_popup = winrt::Microsoft::UI::Content::DesktopPopupSiteBridge::Create(m_ownerIsland); assert(!m_rnWindow); m_rnWindow = winrt::Microsoft::ReactNative::ReactNativeWindow:: @@ -317,11 +389,9 @@ struct CalloutComponentView m_rnWindow.ResizePolicy( winrt::Microsoft::ReactNative::ContentSizePolicy::None); - m_lightDismissRevoker = - winrt::Microsoft::UI::Input::InputLightDismissAction::GetForWindowId( - m_rnWindow.AppWindow().Id()) - .Dismissed(winrt::auto_revoke, - {get_weak(), &CalloutComponentView::OnLightDismissDismissed}); + // InputLightDismissAction does not support weak references, so it cannot use an auto_revoke revoker. + m_lightDismiss = winrt::Microsoft::UI::Input::InputLightDismissAction::GetForWindowId(m_rnWindow.AppWindow().Id()); + m_lightDismissToken = m_lightDismiss.Dismissed({get_weak(), &CalloutComponentView::OnLightDismissDismissed}); if (portal.ContentRoot().Children().Size()) { AdjustWindowSize( @@ -329,11 +399,31 @@ struct CalloutComponentView } } - void RegisterLightDismissAction() noexcept {} + void ReleasePopup() noexcept { + StopWatchingOutsidePointer(); + if (m_lightDismiss) { + m_lightDismiss.Dismissed(m_lightDismissToken); + m_lightDismiss = nullptr; + m_lightDismissToken = {}; + } + if (m_popup && !m_popup.IsClosed()) { + if (m_popup.IsVisible()) m_popup.Hide(); + if (m_rnWindow && m_rnWindow.AppWindow()) m_rnWindow.Close(); + m_popup.Close(); + } + m_rnWindow = nullptr; + m_popup = nullptr; + m_ownerIsland = nullptr; + m_shown = false; + m_positioned = false; + } void Show() noexcept { + if (!m_popup || m_shown) return; if (Modern() && (m_closed || !m_positioned)) return; m_popup.Show(); + m_shown = true; + WatchOutsidePointer(++m_presentation); if (!Modern()) { winrt::Microsoft::UI::Input::InputFocusController::GetForIsland( @@ -354,10 +444,39 @@ struct CalloutComponentView } } + // Outside input dismisses the presentation shown at the time of the input. Owner-island client input arrives through + // the island's pointer source; other windows, including the owner's non-client area, arrive through the hook. + void WatchOutsidePointer(uint64_t presentation) noexcept { + auto dismiss = [wkThis = get_weak(), presentation, dispatcher = m_reactContext.UIDispatcher()]() { + // Dismiss after the input notification returns; the input itself is still delivered to its target. + dispatcher.Post([wkThis, presentation]() { + if (auto strongThis = wkThis.get(); strongThis && strongThis->m_presentation == presentation) { + if (!strongThis->PreventsDismiss("preventDismissOnClickOutside")) { + strongThis->DismissPresentation("native-light-dismiss"); + } + } + }); + }; + m_outsidePointer = OutsidePointerMonitor::Add(winrt::Microsoft::UI::GetWindowFromWindowId(m_popup.WindowId()), dismiss); + if (m_ownerIsland) { + m_ownerPointerSource = winrt::Microsoft::UI::Input::InputPointerSource::GetForIsland(m_ownerIsland); + m_ownerPointerToken = m_ownerPointerSource.PointerPressed([dismiss](const auto &, const auto &) { dismiss(); }); + } + } + + void StopWatchingOutsidePointer() noexcept { + OutsidePointerMonitor::Remove(std::exchange(m_outsidePointer, 0)); + if (m_ownerPointerSource) { + m_ownerPointerSource.PointerPressed(m_ownerPointerToken); + m_ownerPointerSource = nullptr; + m_ownerPointerToken = {}; + } + } + void AdjustWindowSize(const winrt::Microsoft::ReactNative::LayoutMetrics &layoutMetrics) noexcept { m_positioned = false; - if (!m_rnWindow) { + if (!m_rnWindow || !Props()) { return; } @@ -382,7 +501,7 @@ struct CalloutComponentView CompositionUIService::ComponentFromReactTag( m_reactContext.Handle(), Props()->target.AsInt64()); if (!targetView) { - if (Modern() && m_ready) ClosePresentation("anchor-lost"); + if (Modern()) DismissPresentation("anchor-lost"); return; } auto targetPos = ViewToScreenOffset(targetView); @@ -394,12 +513,12 @@ struct CalloutComponentView targetView.LayoutMetrics().Frame.Height * targetScaleFactor); const auto anchorMode = Props()->anchorMode.value_or("legacy"); if (anchorMode == "rect" || anchorMode == "point") { - if (!Props()->anchorRect) { ClosePresentation("anchor-lost"); return; } + if (!Props()->anchorRect) { DismissPresentation("anchor-lost"); return; } const auto &rect = Props()->anchorRect.value(); - targetPos.X += static_cast(rect.screenX * targetScaleFactor); - targetPos.Y += static_cast(rect.screenY * targetScaleFactor); - targetWidthPx = static_cast(rect.width * targetScaleFactor); - targetHeightPx = static_cast(rect.height * targetScaleFactor); + targetPos.X += static_cast(std::lround(rect.screenX * targetScaleFactor)); + targetPos.Y += static_cast(std::lround(rect.screenY * targetScaleFactor)); + targetWidthPx = static_cast(std::lround(rect.width * targetScaleFactor)); + targetHeightPx = static_cast(std::lround(rect.height * targetScaleFactor)); } auto targetRight = targetPos.X + targetWidthPx; auto targetBottom = targetPos.Y + targetHeightPx; @@ -463,6 +582,14 @@ struct CalloutComponentView else if (m_directionalHint == DirectionalHint::RightTopEdge || m_directionalHint == DirectionalHint::RightCenter || m_directionalHint == DirectionalHint::RightBottomEdge) anchorPoint.x += gap; else if (m_directionalHint == DirectionalHint::TopLeftEdge || m_directionalHint == DirectionalHint::TopAutoEdge || m_directionalHint == DirectionalHint::TopCenter || m_directionalHint == DirectionalHint::TopRightEdge) anchorPoint.y -= gap; else anchorPoint.y += gap; + // Expand the excluded anchor along the placement axis so a work-area flip keeps the same gap. + if (flags & TPM_VERTICAL) { + excludeRect.top -= gap; + excludeRect.bottom += gap; + } else { + excludeRect.left -= gap; + excludeRect.right += gap; + } RECT finalPos; @@ -470,7 +597,7 @@ struct CalloutComponentView m_rnWindow.AppWindow().Move({finalPos.left, finalPos.top}); m_positioned = true; - if (Modern() && m_mounted && !m_showQueued && !m_closed && !m_popup.IsVisible()) Show(); + if (Modern() && m_mounted && !m_showQueued && !m_closed && !m_shown) Show(); } else { if (Modern()) return; m_rnWindow.AppWindow().Move({0, 0}); @@ -499,14 +626,20 @@ struct CalloutComponentView bool m_showQueued{false}; bool m_mounted{false}; bool m_positioned{false}; + bool m_shown{false}; + uint64_t m_presentation{0}; + uint64_t m_outsidePointer{0}; + winrt::Microsoft::UI::Input::InputPointerSource m_ownerPointerSource{nullptr}; + winrt::event_token m_ownerPointerToken; bool m_ready{false}; bool m_closed{false}; uint64_t m_lifetime{0}; - HWND m_parentHwnd{nullptr}; winrt::weak_ref m_portal; - winrt::Microsoft::UI::Input::InputLightDismissAction::Dismissed_revoker m_lightDismissRevoker; + winrt::Microsoft::UI::Input::InputLightDismissAction m_lightDismiss{nullptr}; + winrt::event_token m_lightDismissToken; winrt::event_token m_childLayoutMetricsToken; winrt::Microsoft::UI::Content::DesktopPopupSiteBridge m_popup{nullptr}; + winrt::Microsoft::UI::Content::ContentIsland m_ownerIsland{nullptr}; winrt::Microsoft::ReactNative::ReactNativeWindow m_rnWindow{nullptr}; winrt::Microsoft::ReactNative::ReactContext m_reactContext{nullptr}; }; diff --git a/packages/agentic/native-core/windows/NativeCore/shared/FocusRequest.h b/packages/agentic/native-core/windows/NativeCore/shared/FocusRequest.h index 939741602a1..1af3997f822 100644 --- a/packages/agentic/native-core/windows/NativeCore/shared/FocusRequest.h +++ b/packages/agentic/native-core/windows/NativeCore/shared/FocusRequest.h @@ -20,16 +20,20 @@ inline bool Eligible(const rn::ComponentView &view) noexcept { return eligible && eligible.Tag() == view.Tag(); } +// A window is active when it, or the root owner of an owned popup such as a Callout, is the foreground window. +inline bool WindowActive(HWND hwnd) noexcept { + const auto foreground = ::GetForegroundWindow(); + return foreground && hwnd && (::GetAncestor(hwnd, GA_ROOT) == foreground || ::GetAncestor(hwnd, GA_ROOTOWNER) == foreground); +} + inline std::string FocusWithin( const rn::ComponentView &container, int64_t tag, - const std::string &strategy, int64_t defaultTag = 0, HWND allowedOwner = nullptr) noexcept { + const std::string &strategy, int64_t defaultTag = 0) noexcept { if (!container) return "not-mounted"; auto root = container.as().Root(); if (!root) return "not-ready"; auto hwnd = container.as<::Microsoft::ReactNative::Composition::Experimental::IComponentViewInterop>()->GetHwndForParenting(); - auto foreground = ::GetForegroundWindow(); - if (!foreground || (::GetAncestor(hwnd, GA_ROOT) != foreground && - (!allowedOwner || ::GetAncestor(allowedOwner, GA_ROOT) != foreground))) return "inactive-window"; + if (!WindowActive(hwnd)) return "inactive-window"; rn::ComponentView target{nullptr}; if (strategy == "target") target = FindDescendant(container, tag); From 1395571a678686061f7c4195bc22b944883a63c7 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 15 Oct 2026 14:13:29 -0700 Subject: [PATCH 09/15] Record Windows and Win32 requalification after the macOS Paper reconciliation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../native-core/plans/modernization.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/agentic/native-core/plans/modernization.md b/packages/agentic/native-core/plans/modernization.md index 0ace31a6ec3..dae5d6b9ffd 100644 --- a/packages/agentic/native-core/plans/modernization.md +++ b/packages/agentic/native-core/plans/modernization.md @@ -281,6 +281,42 @@ negative monitor origins, collision/flip geometry, scrolled or transformed anchors (the frame-summing position ignores scroll offsets), app deactivation for presentations without focus, and repeated-cycle resource measurements. +#### Reconciliation with the macOS Paper/driver work + +The Windows commit was rebased onto `0ffb15fa3` (macOS Desktop Driver Paper +fixes, Framework Base host focus record, Paper qualification). Only the +modern Callout story, this plan, and one README sentence needed merging; +native, driver, and changeset content is unchanged. The story now reports +lifecycle, operation results, and dismissal on separate labels. + +Upstream's Windows/Win32-reachable changes were checked at source: +`releaseHostFocusRecord` only acts when `TextInput.State.onTextInputBlur` +exists, which RNW 0.81.35 and react-native-win32 0.81.9 do not expose; window +event diagnostics are requested only when a helper advertises `windowEvents`, +which the Windows helper does not; the helper's source digest is unchanged. + +Executed on the reconciled commit: `yarn build`; native-core (18 Node, 30 +Jest), framework-base (184), desktop-driver (61, 3 native-gated skips), and +storybook-desktop (175) tests; Windows `build-driver`/`prep`/`build` (zero +warnings/errors); `native-*` smoke on Windows (170 stories, 18/18 passed, 0 +skipped) and Win32 (152 stories, 7/7 passed, 0 skipped). `yarn clean-lage-cache` +then `yarn lage buildci --continue` passes 170 tasks; only root knip fails, +with the same 11 untouched-package findings as clean `HEAD`. + +Full authored catalogs (no story filter) were also run, because upstream +changed shared Framework Base focus targets and WDIO wait defaults. Every +Windows/Win32 `*--focus-*` story that first clicks an agentic `Input` +(`focus-probe-entry`, `focus-request-entry`, `focus-switch-entry`) fails +because the entry never reports UIA focus. Button focus-management fails +identically on the pre-upstream base `5705e14f1` on both Windows and Win32, +matching a 2026-09-10 Win32 observation, so this is pre-existing and not +caused by either line of work. Separately, some physical clicks +intermittently land at the idle cursor position (two Button stories +navigated to the same sidebar item) or report "Windows did not move the +pointer"; the same cases pass in other runs of the identical commit, and the +base runs show the same error. These catalog gaps remain open and are +outside the native-core lanes. + ### Executed evidence on Windows Executed 2026-10-02 local / 2026-10-03 UTC with Node 24.15.0, React 19.1.4, From b0777780e96e6d7acebb55b1e5ed58c440dbf4ba Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 15 Oct 2026 16:14:03 -0700 Subject: [PATCH 10/15] Add a configurable pointer-focus mode to Framework Base pressables Replace useFocusablePressable's focusOnPress boolean with a pointerFocus mode. The default follows the platform: Windows and Win32 focus a pressed target, and macOS follows AppKit by leaving keyboard focus in place. always and never select one behavior on every platform. Tab maps its tablist exception to never. The FocusZone stories made macOS items click-focusable by calling host focus() directly, which left unmounted Views in React Native's focused-input record and raised a Paper RedBox on a later ScrollView tap. They now use pointerFocus always and a focus target. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/components-pointer-focus.md | 7 +++ .changeset/framework-base-pointer-focus.md | 10 ++++ .../components/src/components/tab/useTab.ts | 2 +- .../focus-zone/focus-zone.stories.tsx | 47 +++++++++++-------- .../src/hooks/pointerFocus.test.ts | 17 +++++++ .../framework-base/src/hooks/pointerFocus.ts | 22 +++++++++ .../src/hooks/useFocusablePressable.native.ts | 10 ++-- .../src/hooks/useFocusablePressable.test.tsx | 27 +++++++++-- .../src/hooks/useFocusablePressable.ts | 6 +-- .../src/hooks/useFocusablePressable.types.ts | 6 +++ packages/framework-base/src/index.ts | 4 +- 11 files changed, 127 insertions(+), 31 deletions(-) create mode 100644 .changeset/components-pointer-focus.md create mode 100644 .changeset/framework-base-pointer-focus.md create mode 100644 packages/framework-base/src/hooks/pointerFocus.test.ts create mode 100644 packages/framework-base/src/hooks/pointerFocus.ts diff --git a/.changeset/components-pointer-focus.md b/.changeset/components-pointer-focus.md new file mode 100644 index 00000000000..9cf4ac5ff9b --- /dev/null +++ b/.changeset/components-pointer-focus.md @@ -0,0 +1,7 @@ +--- +'@fluentui-react-native/components': patch +--- + +Use Framework Base's `pointerFocus` mode for Tab. FocusZone stories request +focus through focus targets so React Native macOS does not keep an unmounted +item as its focused input. diff --git a/.changeset/framework-base-pointer-focus.md b/.changeset/framework-base-pointer-focus.md new file mode 100644 index 00000000000..d2fe9741a34 --- /dev/null +++ b/.changeset/framework-base-pointer-focus.md @@ -0,0 +1,10 @@ +--- +'@fluentui-react-native/framework-base': minor +--- + +Replace `useFocusablePressable`'s `focusOnPress` boolean with a `pointerFocus` +mode. `platform` (the default) keeps the existing behavior: Windows and Win32 +focus a pressed target, and macOS follows AppKit by leaving keyboard focus in +place. `always` and `never` select an explicit behavior on every platform. +Export `PointerFocusMode`, `FocusablePressableOptions`, and +`requestsFocusOnPointerPress`. diff --git a/packages/agentic/components/src/components/tab/useTab.ts b/packages/agentic/components/src/components/tab/useTab.ts index 74bdeb87d69..eb300960fed 100644 --- a/packages/agentic/components/src/components/tab/useTab.ts +++ b/packages/agentic/components/src/components/tab/useTab.ts @@ -98,7 +98,7 @@ export function useTab_unstable(props: TabProps): TabState { onPress?.(event); }, }, - { focusOnPress: !tabList }, + { pointerFocus: tabList ? 'never' : 'platform' }, ); const { focusTarget } = focusBinding; diff --git a/packages/agentic/components/src/primitives/focus-zone/focus-zone.stories.tsx b/packages/agentic/components/src/primitives/focus-zone/focus-zone.stories.tsx index 15af215ab21..11f443be8b7 100644 --- a/packages/agentic/components/src/primitives/focus-zone/focus-zone.stories.tsx +++ b/packages/agentic/components/src/primitives/focus-zone/focus-zone.stories.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import { Platform, Pressable, StyleSheet, Text, View } from 'react-native'; import type { StyleProp, ViewStyle } from 'react-native'; +import { useFocusablePressable, useFocusTarget } from '@fluentui-react-native/framework-base'; import type { WdioStory } from '@fluentui-react-native/storybook-desktop/testing'; import { FocusZone } from '@fluentui-react-native/native-core/legacy'; import type { FocusZoneDirection, FocusZoneProps, FocusZoneTabNavigation } from '@fluentui-react-native/native-core/legacy'; @@ -69,27 +70,29 @@ type FocusZoneStoryButtonProps = { testID: string; }; -const FocusZoneStoryButton = ({ content, focused, nativeID, onFocus, style, testID }: FocusZoneStoryButtonProps) => { - const localRef = React.useRef(null); +/** AppKit keeps keyboard focus where it was on click, but these examples navigate from the clicked item. */ +const ClickFocusedStoryButton = ({ content, focused, nativeID, onFocus, style, testID }: FocusZoneStoryButtonProps) => { + const [pressableProps, , { focusTargetRef }] = useFocusablePressable({ onFocus: () => onFocus() }, { pointerFocus: 'always' }); + return ( + [style, focused && styles.focused, pressed && styles.pressed]} + testID={testID} + > + {content} + + ); +}; +const FocusZoneStoryButton = (props: FocusZoneStoryButtonProps) => { if (Platform.OS === 'macos') { - return ( - localRef.current?.focus()} - ref={localRef} - style={({ pressed }) => [style, focused && styles.focused, pressed && styles.pressed]} - testID={testID} - > - {content} - - ); + return ; } - + const { content, nativeID, onFocus, style, testID } = props; return