git.iko.soy/rowtype-yoga/purescript-yoga-react-native
on main help sign in
Fix EffectFn double-thunk bug in Alert, Menu, Pasteboard JS FFI
commit595f7d58df2d720361c9e0972d8f6522dc446cc5 authorMark <mark.eibes@gmail.com> dateSun 15 Feb 2026, 14:29 (8 months ago) parentec92ca4
5 files changed+368−1
example-macos/macos/YogaReactExample-macOS/RCTMacOSControlsManager.m−1 test/MacOSComponents.test.js+184 test/MacOSImperative.test.js+82 test/MacOSTypes.test.js+81 test/__mocks__/react-native.js+21
example-macos/macos/YogaReactExample-macOS/RCTMacOSControlsManager.m−1
@@ -1608,7 +1608,6 @@ RCT_EXPORT_METHOD(show:(NSString *)style
16081608 resolver:(RCTPromiseResolveBlock)resolve
16091609 rejecter:(RCTPromiseRejectBlock)reject)
16101610{
1611− NSLog(@"[MacOSAlertModule] show called: style=%@ title=%@ message=%@ buttons=%@", style, title, message, buttons);
16121611 dispatch_async(dispatch_get_main_queue(), ^{
16131612 NSAlert *alert = [[NSAlert alloc] init];
16141613 alert.messageText = title ?: @"";
test/MacOSComponents.test.jsnew file+184
@@ -0,0 +1,184 @@
1+import { describe, it, expect } from "vitest";
2+
3+// Batch 1 — existing macOS native components
4+import { _buttonImpl } from "../src/Yoga/React/Native/MacOS/Button.js";
5+import { _segmentedImpl } from "../src/Yoga/React/Native/MacOS/Segmented.js";
6+import { _textFieldImpl } from "../src/Yoga/React/Native/MacOS/TextField.js";
7+import { _sliderImpl } from "../src/Yoga/React/Native/MacOS/Slider.js";
8+import { _switchImpl } from "../src/Yoga/React/Native/MacOS/Switch.js";
9+import { _progressImpl } from "../src/Yoga/React/Native/MacOS/Progress.js";
10+import { _datePickerImpl } from "../src/Yoga/React/Native/MacOS/DatePicker.js";
11+import { _colorWellImpl } from "../src/Yoga/React/Native/MacOS/ColorWell.js";
12+import { _popUpImpl } from "../src/Yoga/React/Native/MacOS/PopUp.js";
13+import { _textEditorImpl } from "../src/Yoga/React/Native/MacOS/TextEditor.js";
14+import { _webViewImpl } from "../src/Yoga/React/Native/MacOS/WebView.js";
15+import { _levelIndicatorImpl } from "../src/Yoga/React/Native/MacOS/LevelIndicator.js";
16+import { _nativeScrollViewImpl } from "../src/Yoga/React/Native/MacOS/ScrollView.js";
17+import { _visualEffectImpl } from "../src/Yoga/React/Native/MacOS/VisualEffect.js";
18+import { _contextMenuImpl } from "../src/Yoga/React/Native/MacOS/ContextMenu.js";
19+import { _filePickerImpl } from "../src/Yoga/React/Native/MacOS/FilePicker.js";
20+import { _videoPlayerImpl } from "../src/Yoga/React/Native/MacOS/VideoPlayer.js";
21+import { _animatedImageImpl } from "../src/Yoga/React/Native/MacOS/AnimatedImage.js";
22+import { _patternBackgroundImpl } from "../src/Yoga/React/Native/MacOS/PatternBackground.js";
23+import { _splitViewImpl } from "../src/Yoga/React/Native/MacOS/SplitView.js";
24+import { _tabViewImpl } from "../src/Yoga/React/Native/MacOS/TabView.js";
25+import { _comboBoxImpl } from "../src/Yoga/React/Native/MacOS/ComboBox.js";
26+import { _stepperImpl } from "../src/Yoga/React/Native/MacOS/Stepper.js";
27+import { _boxImpl } from "../src/Yoga/React/Native/MacOS/Box.js";
28+import { _popoverImpl } from "../src/Yoga/React/Native/MacOS/Popover.js";
29+import { _imageImpl } from "../src/Yoga/React/Native/MacOS/Image.js";
30+
31+// Batch 2 — new macOS native components
32+import { _checkboxImpl } from "../src/Yoga/React/Native/MacOS/Checkbox.js";
33+import { _radioButtonImpl } from "../src/Yoga/React/Native/MacOS/RadioButton.js";
34+import { _searchFieldImpl } from "../src/Yoga/React/Native/MacOS/SearchField.js";
35+import { _tokenFieldImpl } from "../src/Yoga/React/Native/MacOS/TokenField.js";
36+import { _separatorImpl } from "../src/Yoga/React/Native/MacOS/Separator.js";
37+import { _helpButtonImpl } from "../src/Yoga/React/Native/MacOS/HelpButton.js";
38+import { _pathControlImpl } from "../src/Yoga/React/Native/MacOS/PathControl.js";
39+import { _sheetImpl } from "../src/Yoga/React/Native/MacOS/Sheet.js";
40+import { _tableViewImpl } from "../src/Yoga/React/Native/MacOS/TableView.js";
41+
42+describe("macOS native component FFI exports (batch 1)", () => {
43+ it("Button resolves to NativeButton", () => {
44+ expect(_buttonImpl).toBe("NativeButton");
45+ });
46+
47+ it("Segmented resolves to NativeSegmented", () => {
48+ expect(_segmentedImpl).toBe("NativeSegmented");
49+ });
50+
51+ it("TextField resolves to NativeTextField", () => {
52+ expect(_textFieldImpl).toBe("NativeTextField");
53+ });
54+
55+ it("Slider resolves to NativeSlider", () => {
56+ expect(_sliderImpl).toBe("NativeSlider");
57+ });
58+
59+ it("Switch resolves to NativeSwitch", () => {
60+ expect(_switchImpl).toBe("NativeSwitch");
61+ });
62+
63+ it("Progress resolves to NativeProgress", () => {
64+ expect(_progressImpl).toBe("NativeProgress");
65+ });
66+
67+ it("DatePicker resolves to NativeDatePicker", () => {
68+ expect(_datePickerImpl).toBe("NativeDatePicker");
69+ });
70+
71+ it("ColorWell resolves to NativeColorWell", () => {
72+ expect(_colorWellImpl).toBe("NativeColorWell");
73+ });
74+
75+ it("PopUp resolves to NativePopUp", () => {
76+ expect(_popUpImpl).toBe("NativePopUp");
77+ });
78+
79+ it("TextEditor resolves to NativeTextEditor", () => {
80+ expect(_textEditorImpl).toBe("NativeTextEditor");
81+ });
82+
83+ it("WebView resolves to NativeWebView", () => {
84+ expect(_webViewImpl).toBe("NativeWebView");
85+ });
86+
87+ it("LevelIndicator resolves to NativeLevelIndicator", () => {
88+ expect(_levelIndicatorImpl).toBe("NativeLevelIndicator");
89+ });
90+
91+ it("ScrollView resolves to MacOSScrollView", () => {
92+ expect(_nativeScrollViewImpl).toBe("MacOSScrollView");
93+ });
94+
95+ it("VisualEffect resolves to MacOSVisualEffectView", () => {
96+ expect(_visualEffectImpl).toBe("MacOSVisualEffectView");
97+ });
98+
99+ it("ContextMenu resolves to MacOSContextMenu", () => {
100+ expect(_contextMenuImpl).toBe("MacOSContextMenu");
101+ });
102+
103+ it("FilePicker resolves to MacOSFilePicker", () => {
104+ expect(_filePickerImpl).toBe("MacOSFilePicker");
105+ });
106+
107+ it("VideoPlayer resolves to MacOSVideoPlayer", () => {
108+ expect(_videoPlayerImpl).toBe("MacOSVideoPlayer");
109+ });
110+
111+ it("AnimatedImage resolves to MacOSAnimatedImage", () => {
112+ expect(_animatedImageImpl).toBe("MacOSAnimatedImage");
113+ });
114+
115+ it("PatternBackground resolves to MacOSPatternBackground", () => {
116+ expect(_patternBackgroundImpl).toBe("MacOSPatternBackground");
117+ });
118+
119+ it("SplitView resolves to MacOSSplitView", () => {
120+ expect(_splitViewImpl).toBe("MacOSSplitView");
121+ });
122+
123+ it("TabView resolves to MacOSTabView", () => {
124+ expect(_tabViewImpl).toBe("MacOSTabView");
125+ });
126+
127+ it("ComboBox resolves to MacOSComboBox", () => {
128+ expect(_comboBoxImpl).toBe("MacOSComboBox");
129+ });
130+
131+ it("Stepper resolves to MacOSStepper", () => {
132+ expect(_stepperImpl).toBe("MacOSStepper");
133+ });
134+
135+ it("Box resolves to MacOSBox", () => {
136+ expect(_boxImpl).toBe("MacOSBox");
137+ });
138+
139+ it("Popover resolves to MacOSPopover", () => {
140+ expect(_popoverImpl).toBe("MacOSPopover");
141+ });
142+
143+ it("Image resolves to MacOSImage", () => {
144+ expect(_imageImpl).toBe("MacOSImage");
145+ });
146+});
147+
148+describe("macOS native component FFI exports (batch 2)", () => {
149+ it("Checkbox resolves to MacOSCheckbox", () => {
150+ expect(_checkboxImpl).toBe("MacOSCheckbox");
151+ });
152+
153+ it("RadioButton resolves to MacOSRadioButton", () => {
154+ expect(_radioButtonImpl).toBe("MacOSRadioButton");
155+ });
156+
157+ it("SearchField resolves to MacOSSearchField", () => {
158+ expect(_searchFieldImpl).toBe("MacOSSearchField");
159+ });
160+
161+ it("TokenField resolves to MacOSTokenField", () => {
162+ expect(_tokenFieldImpl).toBe("MacOSTokenField");
163+ });
164+
165+ it("Separator resolves to MacOSSeparator", () => {
166+ expect(_separatorImpl).toBe("MacOSSeparator");
167+ });
168+
169+ it("HelpButton resolves to MacOSHelpButton", () => {
170+ expect(_helpButtonImpl).toBe("MacOSHelpButton");
171+ });
172+
173+ it("PathControl resolves to MacOSPathControl", () => {
174+ expect(_pathControlImpl).toBe("MacOSPathControl");
175+ });
176+
177+ it("Sheet resolves to MacOSSheet", () => {
178+ expect(_sheetImpl).toBe("MacOSSheet");
179+ });
180+
181+ it("TableView resolves to MacOSTableView", () => {
182+ expect(_tableViewImpl).toBe("MacOSTableView");
183+ });
184+});
test/MacOSImperative.test.jsnew file+82
@@ -0,0 +1,82 @@
1+import { describe, it, expect, vi } from "vitest";
2+import { NativeModules } from "react-native";
3+import { alertImpl } from "../src/Yoga/React/Native/MacOS/Alert.js";
4+import { showMenuImpl } from "../src/Yoga/React/Native/MacOS/Menu.js";
5+import {
6+ copyToClipboardImpl,
7+ readClipboardImpl,
8+} from "../src/Yoga/React/Native/MacOS/Pasteboard.js";
9+
10+describe("macOS Alert FFI", () => {
11+ it("alertImpl calls NativeModules.MacOSAlertModule.show", () => {
12+ const spy = vi.spyOn(NativeModules.MacOSAlertModule, "show");
13+ alertImpl("warning", "Title", "Message", ["OK", "Cancel"]);
14+ expect(spy).toHaveBeenCalledWith("warning", "Title", "Message", [
15+ "OK",
16+ "Cancel",
17+ ]);
18+ spy.mockRestore();
19+ });
20+
21+ it("alertImpl passes style through", () => {
22+ const spy = vi.spyOn(NativeModules.MacOSAlertModule, "show");
23+ alertImpl("critical", "Error", "Something broke", ["Retry"]);
24+ expect(spy).toHaveBeenCalledWith("critical", "Error", "Something broke", [
25+ "Retry",
26+ ]);
27+ spy.mockRestore();
28+ });
29+
30+ it("alertImpl does not throw with empty buttons", () => {
31+ expect(() => alertImpl("informational", "Info", "Note", [])).not.toThrow();
32+ });
33+});
34+
35+describe("macOS Menu FFI", () => {
36+ it("showMenuImpl calls NativeModules.MacOSMenuModule.show with items", () => {
37+ const spy = vi.spyOn(NativeModules.MacOSMenuModule, "show");
38+ const items = [
39+ { title: "Cut", id: "cut" },
40+ { title: "Copy", id: "copy" },
41+ ];
42+ const callback = (_id) => () => {};
43+ showMenuImpl(items, callback);
44+ expect(spy).toHaveBeenCalledWith(items);
45+ spy.mockRestore();
46+ });
47+
48+ it("showMenuImpl invokes callback when item selected", async () => {
49+ const selectedId = "paste";
50+ vi.spyOn(NativeModules.MacOSMenuModule, "show").mockResolvedValueOnce(
51+ selectedId,
52+ );
53+ let result = "";
54+ const callback = (id) => () => {
55+ result = id;
56+ };
57+ showMenuImpl([{ title: "Paste", id: "paste" }], callback);
58+ await new Promise((r) => setTimeout(r, 10));
59+ expect(result).toBe("paste");
60+ });
61+
62+ it("showMenuImpl does not invoke callback when no item selected", async () => {
63+ vi.spyOn(NativeModules.MacOSMenuModule, "show").mockResolvedValueOnce("");
64+ let called = false;
65+ const callback = (_id) => () => {
66+ called = true;
67+ };
68+ showMenuImpl([{ title: "Save", id: "save" }], callback);
69+ await new Promise((r) => setTimeout(r, 10));
70+ expect(called).toBe(false);
71+ });
72+});
73+
74+describe("macOS Pasteboard FFI", () => {
75+ it("copyToClipboardImpl does not throw", () => {
76+ expect(() => copyToClipboardImpl("Hello from tests")).not.toThrow();
77+ });
78+
79+ it("readClipboardImpl does not throw", () => {
80+ expect(() => readClipboardImpl()).not.toThrow();
81+ });
82+});
test/MacOSTypes.test.jsnew file+81
@@ -0,0 +1,81 @@
1+import { describe, it, expect } from "vitest";
2+
3+// Import compiled PureScript output — newtypes are transparent at runtime
4+import * as Types from "../output/Yoga.React.Native.MacOS.Types/index.js";
5+
6+describe("macOS Types — BezelStyle", () => {
7+ it("push = 'push'", () => expect(Types.push).toBe("push"));
8+ it("toolbar = 'toolbar'", () => expect(Types.toolbar).toBe("toolbar"));
9+ it("texturedSquare = 'texturedSquare'", () =>
10+ expect(Types.texturedSquare).toBe("texturedSquare"));
11+ it("inline = 'inline'", () => expect(Types.inline).toBe("inline"));
12+});
13+
14+describe("macOS Types — VisualEffectMaterial", () => {
15+ it("sidebar = 'sidebar'", () => expect(Types.sidebar).toBe("sidebar"));
16+ it("headerView = 'headerView'", () =>
17+ expect(Types.headerView).toBe("headerView"));
18+ it("titlebar = 'titlebar'", () => expect(Types.titlebar).toBe("titlebar"));
19+ it("contentBackground = 'contentBackground'", () =>
20+ expect(Types.contentBackground).toBe("contentBackground"));
21+ it("menu = 'menu'", () => expect(Types.menu).toBe("menu"));
22+});
23+
24+describe("macOS Types — BlendingMode", () => {
25+ it("withinWindow = 'withinWindow'", () =>
26+ expect(Types.withinWindow).toBe("withinWindow"));
27+ it("behindWindow = 'behindWindow'", () =>
28+ expect(Types.behindWindow).toBe("behindWindow"));
29+});
30+
31+describe("macOS Types — ControlsStyle", () => {
32+ it("noControls = 'none'", () => expect(Types.noControls).toBe("none"));
33+ it("inlineControls = 'inline'", () =>
34+ expect(Types.inlineControls).toBe("inline"));
35+ it("floatingControls = 'floating'", () =>
36+ expect(Types.floatingControls).toBe("floating"));
37+ it("defaultControls = 'default'", () =>
38+ expect(Types.defaultControls).toBe("default"));
39+});
40+
41+describe("macOS Types — PopoverEdge", () => {
42+ it("bottom = 'bottom'", () => expect(Types.bottom).toBe("bottom"));
43+ it("top = 'top'", () => expect(Types.top).toBe("top"));
44+ it("left = 'left'", () => expect(Types.left).toBe("left"));
45+ it("right = 'right'", () => expect(Types.right).toBe("right"));
46+});
47+
48+describe("macOS Types — AlertStyle", () => {
49+ it("warning = 'warning'", () => expect(Types.warning).toBe("warning"));
50+ it("critical = 'critical'", () => expect(Types.critical).toBe("critical"));
51+ it("informational = 'informational'", () =>
52+ expect(Types.informational).toBe("informational"));
53+});
54+
55+describe("macOS Types — PathControlStyle", () => {
56+ it("standardPath = 'standard'", () =>
57+ expect(Types.standardPath).toBe("standard"));
58+ it("popupPath = 'popup'", () => expect(Types.popupPath).toBe("popup"));
59+});
60+
61+describe("macOS Types — ImageContentMode", () => {
62+ it("scaleToFit = 'scaleToFit'", () =>
63+ expect(Types.scaleToFit).toBe("scaleToFit"));
64+ it("center = 'center'", () => expect(Types.center).toBe("center"));
65+ it("scaleProportionally = 'scaleProportionally'", () =>
66+ expect(Types.scaleProportionally).toBe("scaleProportionally"));
67+});
68+
69+describe("macOS Types — FilePickerMode", () => {
70+ it("openFile = 'open'", () => expect(Types.openFile).toBe("open"));
71+ it("saveFile = 'save'", () => expect(Types.saveFile).toBe("save"));
72+});
73+
74+describe("macOS Types — ToolbarStyle", () => {
75+ it("unified = 'unified'", () => expect(Types.unified).toBe("unified"));
76+ it("expanded = 'expanded'", () => expect(Types.expanded).toBe("expanded"));
77+ it("preference = 'preference'", () =>
78+ expect(Types.preference).toBe("preference"));
79+ it("unifiedCompact = 'unifiedCompact'", () =>
80+ expect(Types.unifiedCompact).toBe("unifiedCompact"));
81+});
test/__mocks__/react-native.js+21
@@ -266,6 +266,24 @@ const AppRegistry = {
266266 registerComponent: (name, getComponent) => {},
267267};
268268
269+// UIManager — returns a truthy config for any component name
270+const UIManager = {
271+ getViewManagerConfig: (name) => ({ name }),
272+};
273+
274+// requireNativeComponent — returns the component name as a string tag
275+const requireNativeComponent = (name) => name;
276+
277+// NativeModules — stubs for imperative native modules
278+const NativeModules = {
279+ MacOSAlertModule: {
280+ show: (style, title, message, buttons) => Promise.resolve(0),
281+ },
282+ MacOSMenuModule: {
283+ show: (items) => Promise.resolve(""),
284+ },
285+};
286+
269287export {
270288 View,
271289 Text,
@@ -311,4 +329,7 @@ export {
311329 Platform,
312330 Share,
313331 AppRegistry,
332+ UIManager,
333+ requireNativeComponent,
334+ NativeModules,
314335};