diff --git a/.eslintrc.js b/.eslintrc.js index 28ec2643ca..ba0ae7c59a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -62,7 +62,7 @@ module.exports = { ], '@typescript-eslint/no-unused-vars': [ 'error', - { ignoreRestSiblings: true}, + { ignoreRestSiblings: true, argsIgnorePattern: '^_' }, ], '@typescript-eslint/consistent-type-definitions': [ 'error', diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index fbaba471ac..d6ef407ccc 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -12,18 +12,23 @@ jobs: runs-on: ${{ matrix.os }} steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v7 - name: Set up Node.js - uses: actions/setup-node@v2.5.1 + uses: actions/setup-node@v7 with: - node-version: 16 + node-version: 22 - name: Set up JDK - uses: actions/setup-java@v2.5.0 + uses: actions/setup-java@v6 with: distribution: temurin java-version: 11 + # setup-gradle validates every Gradle wrapper JAR in the workspace. Running it + # before node_modules exists keeps it to our own wrapper, since some packages + # ship wrapper JARs that aren't on Gradle's list of known checksums. + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6 - name: Cache /node_modules - uses: actions/cache@v2 + uses: actions/cache@v6 with: path: node_modules key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} @@ -31,9 +36,7 @@ jobs: run: yarn --frozen-lockfile shell: bash - name: Build Android test app - uses: gradle/gradle-build-action@v2.1.3 - with: - gradle-version: wrapper - arguments: --no-daemon clean build check test - build-root-directory: example/android + run: ./gradlew --no-daemon clean build check test + shell: bash + working-directory: example/android timeout-minutes: 60 diff --git a/.github/workflows/detox.yml b/.github/workflows/detox.yml index a1b67f9b80..2479743f9b 100644 --- a/.github/workflows/detox.yml +++ b/.github/workflows/detox.yml @@ -4,12 +4,12 @@ on: [pull_request] jobs: tests: name: 'Running tests' - runs-on: 'macOS-latest' + runs-on: macos-15 steps: - - uses: actions/checkout@master - - uses: actions/setup-node@master + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: - node-version: 14 + node-version: 22 - name: Setup - Install Yarn run: YARN_GPG=NO curl -o- -L https://yarnpkg.com/install.sh | bash - name: Setup - Install NPM Dependencies diff --git a/.github/workflows/ios-ci.yml b/.github/workflows/ios-ci.yml index 997e05d607..d3c326ac95 100644 --- a/.github/workflows/ios-ci.yml +++ b/.github/workflows/ios-ci.yml @@ -6,30 +6,40 @@ on: pull_request: jobs: build: - runs-on: macos-latest + runs-on: macos-15 steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v7 - name: Set up Node.js - uses: actions/setup-node@v2.5.1 + uses: actions/setup-node@v7 with: - node-version: 16 + node-version: 22 - name: Cache /node_modules - uses: actions/cache@v2 + uses: actions/cache@v6 with: path: node_modules key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} - name: Install npm dependencies run: yarn --frozen-lockfile + # TODO(rn-upgrade): Remove once the example app is on RN 0.86, which already fixes this. + # Boost moved its release archives off JFrog, so the old URL in this podspec + # no longer serves the tarball and pod install fails the checksum check. + - name: Point boost podspec at archives.boost.io + run: sed -i '' 's#https://boostorg.jfrog.io/artifactory/main/release/#https://archives.boost.io/release/#' node_modules/react-native/third-party-podspecs/boost.podspec + # TODO(rn-upgrade): Remove once the example app is on RN 0.86, which already fixes this. + # Newer Xcode warns about bitwise | on booleans, and Yoga's podspec builds with + # -Werror, so two hadOverflow() lines in RN 0.68's Yoga break the build. + - name: Fix Yoga bitwise-or for current Xcode + run: sed -i '' 's/getLayout().hadOverflow() |$/getLayout().hadOverflow() ||/' node_modules/react-native/ReactCommon/yoga/yoga/Yoga.cpp - name: Install Pods run: pod install working-directory: example/ios + # TODO(rn-upgrade): Remove once the example app is on RN 0.86, which already fixes this. + # Boost 1.76 uses std::unary_function, which Xcode 16's libc++ drops in C++17 + # mode. This define brings it back for every target, and $(inherited) keeps + # each target's own preprocessor definitions. - name: Build iOS test app run: | - device_name='iPhone 13' - device=$(xcrun simctl list devices "${device_name}" available | grep "${device_name} (") - re='\(([-0-9A-Fa-f]+)\)' - [[ $device =~ $re ]] || exit 1 - xcodebuild -workspace WebviewExample.xcworkspace -scheme ReactTestApp -destination "platform=iOS Simulator,id=${BASH_REMATCH[1]}" CODE_SIGNING_ALLOWED=NO COMPILER_INDEX_STORE_ENABLE=NO build + xcodebuild -workspace WebviewExample.xcworkspace -scheme ReactTestApp -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO COMPILER_INDEX_STORE_ENABLE=NO GCC_PREPROCESSOR_DEFINITIONS='$(inherited) _LIBCPP_ENABLE_CXX17_REMOVED_UNARY_BINARY_FUNCTION' build working-directory: example/ios timeout-minutes: 60 diff --git a/.github/workflows/macos-ci.yml b/.github/workflows/macos-ci.yml index d8f1c959ad..6bc365243f 100644 --- a/.github/workflows/macos-ci.yml +++ b/.github/workflows/macos-ci.yml @@ -1,31 +1,45 @@ name: macOS +# Discord doesn't ship this fork on macOS, and several webViewKey code paths use UIKit-only APIs +# with no macOS branch, so this build doesn't compile. It only runs when triggered by hand from +# the Actions tab until someone takes on macOS support. on: - push: - branches: - - master - pull_request: + workflow_dispatch: jobs: build: - runs-on: macos-latest + runs-on: macos-15 steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v7 - name: Set up Node.js - uses: actions/setup-node@v2.5.1 + uses: actions/setup-node@v7 with: - node-version: 16 + node-version: 22 - name: Cache /node_modules - uses: actions/cache@v2 + uses: actions/cache@v6 with: path: node_modules key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} - name: Install npm dependencies run: yarn --frozen-lockfile + # TODO(rn-upgrade): Remove once the example app is on RN 0.86, which already fixes this. + # Boost moved its release archives off JFrog, so the old URL in this podspec + # no longer serves the tarball and pod install fails the checksum check. + - name: Point boost podspec at archives.boost.io + run: sed -i '' 's#https://boostorg.jfrog.io/artifactory/main/release/#https://archives.boost.io/release/#' node_modules/react-native-macos/third-party-podspecs/boost.podspec + # TODO(rn-upgrade): Remove once the example app is on RN 0.86, which already fixes this. + # Newer Xcode warns about bitwise | on booleans, and Yoga's podspec builds with + # -Werror, so two hadOverflow() lines in RN 0.68's Yoga break the build. + - name: Fix Yoga bitwise-or for current Xcode + run: sed -i '' 's/getLayout().hadOverflow() |$/getLayout().hadOverflow() ||/' node_modules/react-native-macos/ReactCommon/yoga/yoga/Yoga.cpp - name: Install Pods run: pod install working-directory: example/macos + # TODO(rn-upgrade): Remove once the example app is on RN 0.86, which already fixes this. + # Boost 1.76 uses std::unary_function, which Xcode 16's libc++ drops in C++17 + # mode. This define brings it back for every target, and $(inherited) keeps + # each target's own preprocessor definitions. - name: Build macOS test app run: | - xcodebuild -workspace WebviewExample.xcworkspace -scheme ReactTestApp CODE_SIGNING_ALLOWED=NO COMPILER_INDEX_STORE_ENABLE=NO build + xcodebuild -workspace WebviewExample.xcworkspace -scheme ReactTestApp CODE_SIGNING_ALLOWED=NO COMPILER_INDEX_STORE_ENABLE=NO GCC_PREPROCESSOR_DEFINITIONS='$(inherited) _LIBCPP_ENABLE_CXX17_REMOVED_UNARY_BINARY_FUNCTION' build working-directory: example/macos timeout-minutes: 60 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index ebee3eb173..b1559e5961 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -7,11 +7,11 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v3.0.14 + - uses: actions/stale@v11 with: repo-token: ${{ secrets.GITHUB_TOKEN }} stale-issue-message: 'Hello 👋, this issue has been opened for more than 2 months with no activity on it. If the issue is still here, please keep in mind that we need community support and help to fix it! Just comment something like _still searching for solutions_ and if you found one, please open a pull request! You have 7 days until this gets closed automatically' stale-pr-message: 'Hello 👋, this PR has been opened for more than 2 months with no activity on it. If you think this is a mistake please comment and ping a maintainer to get this merged ASAP! Thanks for contributing! You have 7 days until this gets closed automatically' - exempt-issue-label: 'Keep opened' - exempt-pr-label: 'Keep opened' + exempt-issue-labels: 'Keep opened' + exempt-pr-labels: 'Keep opened' remove-stale-when-updated: true diff --git a/.github/workflows/windows-ci.yml b/.github/workflows/windows-ci.yml index d56326a0a4..b853c9b431 100644 --- a/.github/workflows/windows-ci.yml +++ b/.github/workflows/windows-ci.yml @@ -4,22 +4,28 @@ on: [pull_request] jobs: run-windows-tests: name: Build & run tests - runs-on: windows-2019 + # TODO(rn-upgrade): windows-2022 has the Windows SDK 10.0.19041.0 that react-native-windows + # 0.68 targets. Revisit once react-native-windows is upgraded. + runs-on: windows-2022 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v7 name: Checkout Code + # TODO(rn-upgrade): Remove once react-native-windows is upgraded. + # The react-native-windows 0.68 CLI spawns .cmd files without a shell, which Node + # rejects with EINVAL starting in 20.12.2 (CVE-2024-27980). 20.12.1 is the newest + # release that still allows it. - name: Setup Node.js - uses: actions/setup-node@v1 + uses: actions/setup-node@v7 with: - node-version: '^14' + node-version: 20.12.1 - name: Setup MSBuild - uses: microsoft/setup-msbuild@v1.0.2 + uses: microsoft/setup-msbuild@v3 - name: Check node modules cache - uses: actions/cache@v1 + uses: actions/cache@v6 id: yarn-cache with: path: ./node_modules diff --git a/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewManager.java b/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewManager.java index ef58458788..bd1268a430 100644 --- a/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewManager.java +++ b/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewManager.java @@ -349,6 +349,9 @@ public void setWebViewKey(RNCWebViewContainer view, String webViewKey) { webView.setWebViewKey(webViewKey); RNCWebViewMapManager.INSTANCE.getViewIdMap().put(webView.getId(), view.getId()); rncWebViewMap.put(webViewKey, webView); + // Let any RNCWebViewTargetView waiting on this key borrow the view now that it's registered. Deferred to the + // next frame so the builder finishes attaching it before a target reparents it away. + view.post(() -> RNCWebViewMapManager.INSTANCE.notifyWebViewRegistered(webViewKey)); }); } @@ -2073,7 +2076,11 @@ protected void dispatchEvent(WebView webView, Event event) { return; } - uiManager.getEventDispatcher().dispatchEvent(event); + // TODO(rn-upgrade): Once the example app is on RN 0.86, this can be chained again. + // Older React Native declares getEventDispatcher() as a generic T, so we + // assign it to a typed local to give Java something to infer T from. + EventDispatcher eventDispatcher = uiManager.getEventDispatcher(); + eventDispatcher.dispatchEvent(event); } protected void cleanupCallbacksAndDestroy() { diff --git a/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewMapManager.kt b/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewMapManager.kt index b1b20aa0a1..0d5e351e3a 100644 --- a/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewMapManager.kt +++ b/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewMapManager.kt @@ -15,4 +15,23 @@ object RNCWebViewMapManager { // This map facilitates finding the viewId of the wrapper RNCWebView from the WebView // view Id. val viewIdMap = mutableMapOf(); + + // Listeners notified (with the webViewKey) when a WebView is registered, so a RNCWebViewTargetView already + // waiting on that key can borrow it as soon as it exists rather than racing the builder that creates it. + private val registrationListeners = mutableListOf<(String) -> Unit>() + + fun addRegistrationListener(listener: (String) -> Unit) { + registrationListeners.add(listener) + } + + fun removeRegistrationListener(listener: (String) -> Unit) { + registrationListeners.remove(listener) + } + + fun notifyWebViewRegistered(webViewKey: String) { + // Copy first: a listener may borrow synchronously and mutate the list. + for (listener in registrationListeners.toList()) { + listener(webViewKey) + } + } } diff --git a/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewPackage.kt b/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewPackage.kt index 2b74c74aa9..9bd21ba6ec 100644 --- a/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewPackage.kt +++ b/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewPackage.kt @@ -10,6 +10,7 @@ class RNCWebViewPackage: ReactPackage { ) override fun createViewManagers(reactContext: ReactApplicationContext) = listOf( - RNCWebViewManager() + RNCWebViewManager(), + RNCWebViewTargetManager() ) } diff --git a/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewTargetManager.kt b/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewTargetManager.kt new file mode 100644 index 0000000000..e0e94fa3da --- /dev/null +++ b/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewTargetManager.kt @@ -0,0 +1,148 @@ +package com.reactnativecommunity.webview + +import android.view.View +import android.view.ViewGroup +import android.webkit.WebView +import android.widget.FrameLayout +import com.facebook.react.bridge.ReactContext +import com.facebook.react.uimanager.SimpleViewManager +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.UIManagerModule +import com.facebook.react.uimanager.annotations.ReactProp + +/** + * A lightweight display target for a WebView that already lives in [RNCWebViewMapManager]. Where RNCWebView + * builds, configures, and owns a WebView, this only borrows one by its webViewKey and shows it. It never + * re-points the WebChromeClient, never writes the viewId map or the shared map, so the RNCWebViewContainer that + * built the WebView stays its owner: onMessage and load callbacks keep routing to that builder while this target + * merely displays the view. On unmount it hands the WebView back to its temporary parent so it survives for the + * next target rather than being torn down. + */ +class RNCWebViewTargetView(context: ThemedReactContext) : FrameLayout(context) { + init { + // React adds the WebView to us without measuring or laying it out (facebook/react-native#17968), so on its own + // it keeps whatever size it had in the pool and leaves a blank gap. We fix that by laying out the CHILD to fill + // us, at our top-left. It has to be the child, never the parent: React owns our own frame and has already placed + // us below the channel header, so re-laying the parent out at (0,0) would yank the whole target up under the + // header and leave a blank strip at the bottom where it no longer reaches. + setOnHierarchyChangeListener(object : ViewGroup.OnHierarchyChangeListener { + override fun onChildViewAdded(parent: View, child: View) { + child.measure( + View.MeasureSpec.makeMeasureSpec(parent.measuredWidth, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(parent.measuredHeight, View.MeasureSpec.EXACTLY), + ) + child.layout(0, 0, parent.measuredWidth, parent.measuredHeight) + } + + override fun onChildViewRemoved(parent: View, child: View) {} + }) + } + + var webViewKey: String? = null + set(value) { + field = value + borrow() + } + var temporaryParentNodeTag: Int = 0 + + // Borrow as soon as our builder registers the WebView, in case it mounted after us — the counterpart to the + // borrow() we run on attach for the case where the builder was already there. + private val registrationListener: (String) -> Unit = { key -> + if (key == webViewKey) { + borrow() + } + } + + private fun mappedWebView(): WebView? { + val key = webViewKey ?: return null + return RNCWebViewMapManager.rncWebViewMap[key] + } + + fun borrow() { + val webView = mappedWebView() + if (webView == null) { + return + } + if (webView.parent === this) { + return + } + // Detach from wherever it currently lives — the builder, another target, or the temporary parent — by + // reparenting. We deliberately leave the builder's ownership bookkeeping (viewId map, chrome client) alone; + // we're only moving the view, not taking it over. + (webView.parent as? ViewGroup)?.removeView(webView) + addView(webView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + RNCWebViewMapManager.addRegistrationListener(registrationListener) + // The builder may already have created the WebView; grab it now. If not, registrationListener catches it. + borrow() + } + + override fun onDetachedFromWindow() { + RNCWebViewMapManager.removeRegistrationListener(registrationListener) + super.onDetachedFromWindow() + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + // Our real size can arrive after the WebView was borrowed in (the onHierarchyChange pass may have run before + // we were measured), so re-lay it out to our current bounds here to keep the guest filling us exactly. + val webView = mappedWebView() ?: return + if (webView.parent === this) { + webView.measure( + View.MeasureSpec.makeMeasureSpec(w, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(h, View.MeasureSpec.EXACTLY), + ) + webView.layout(0, 0, w, h) + } + } + + fun returnToHome() { + val webView = mappedWebView() ?: return + // Only return it if we're the one currently showing it — if another target already stole it, it owns the + // hand-off now. + if (webView.parent !== this) { + return + } + removeView(webView) + if (temporaryParentNodeTag == 0) { + return + } + val uiManager = (context as ReactContext).getNativeModule(UIManagerModule::class.java) ?: return + val home = uiManager.resolveView(temporaryParentNodeTag) as? ViewGroup ?: return + home.addView(webView) + // Resize to match the home, mirroring how RNCWebViewManager returns a view to its temporary parent. + webView.measure( + MeasureSpec.makeMeasureSpec(home.measuredWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(home.measuredHeight, MeasureSpec.EXACTLY) + ) + webView.layout(0, 0, webView.measuredWidth, webView.measuredHeight) + } +} + +class RNCWebViewTargetManager : SimpleViewManager() { + override fun getName() = REACT_CLASS + + public override fun createViewInstance(reactContext: ThemedReactContext) = RNCWebViewTargetView(reactContext) + + @ReactProp(name = "webViewKey") + fun setWebViewKey(view: RNCWebViewTargetView, webViewKey: String?) { + view.webViewKey = webViewKey + } + + @ReactProp(name = "temporaryParentNodeTag") + fun setTemporaryParentNodeTag(view: RNCWebViewTargetView, tag: Int) { + view.temporaryParentNodeTag = tag + } + + override fun onDropViewInstance(view: RNCWebViewTargetView) { + super.onDropViewInstance(view) + view.returnToHome() + } + + companion object { + const val REACT_CLASS = "RNCWebViewTarget" + } +} diff --git a/apple/RNCWebView.m b/apple/RNCWebView.m index a97e25c251..fd7bc6492e 100644 --- a/apple/RNCWebView.m +++ b/apple/RNCWebView.m @@ -12,6 +12,7 @@ #import #import "RNCWKProcessPoolManager.h" #import "RNCWKWebViewMapManager.h" +#import "RNCWebViewTarget.h" #import "RNCScriptMessageManager.h" #import "ScriptMessageEventEmitter.h" @@ -512,6 +513,16 @@ - (void)didMoveToWindow if (!reusedWebViewInstance) { [self visitSource]; } + + // Now that this WebView exists and is registered under its key, let any RNCWebViewTarget already waiting on + // that key borrow it. Deferred to the next runloop so we finish attaching it here before a target reparents + // it away. + if ([self shouldReuseWebView] && _webViewKey != nil) { + NSString *registeredKey = _webViewKey; + dispatch_async(dispatch_get_main_queue(), ^{ + [[NSNotificationCenter defaultCenter] postNotificationName:RNCWebViewDidRegisterNotification object:registeredKey]; + }); + } } #if !TARGET_OS_OSX // Allow this object to recognize gestures diff --git a/apple/RNCWebViewTarget.h b/apple/RNCWebViewTarget.h new file mode 100644 index 0000000000..d3924e6ba2 --- /dev/null +++ b/apple/RNCWebViewTarget.h @@ -0,0 +1,19 @@ +/** + * A lightweight display target for a WebView that already exists in the shared map. Where RNCWebView builds, + * configures, and owns a WKWebView, RNCWebViewTarget only borrows one by its webViewKey and shows it here. It + * never sets a source, a user agent, a delegate, or any configuration, so the RNCWebView that built the view + * stays its owner — navigation callbacks and the message stream keep flowing there while this target merely + * displays it. On unmount it hands the view back to its temporary parent so it survives for the next target. + */ +#import +#import + +// Posted (object = webViewKey) by RNCWebView when it registers a WebView, so a target waiting on that key borrows +// it as soon as it exists rather than racing the builder. +extern NSString *const RNCWebViewDidRegisterNotification; + +@interface RNCWebViewTarget : RCTView +@property (nonatomic, copy) NSString * _Nullable webViewKey; +@property (nonatomic, copy) NSNumber * _Nullable temporaryParentNodeTag; +@property (nonatomic, weak) RCTBridge * _Nullable bridge; +@end diff --git a/apple/RNCWebViewTarget.m b/apple/RNCWebViewTarget.m new file mode 100644 index 0000000000..5cdfa37d8d --- /dev/null +++ b/apple/RNCWebViewTarget.m @@ -0,0 +1,120 @@ +#import "RNCWebViewTarget.h" +#import "RNCWKWebViewMapManager.h" + +#import +#import + +NSString *const RNCWebViewDidRegisterNotification = @"RNCWebViewDidRegister"; + +@implementation RNCWebViewTarget + +- (instancetype)initWithFrame:(CGRect)frame +{ + if (self = [super initWithFrame:frame]) { + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(onWebViewRegistered:) + name:RNCWebViewDidRegisterNotification + object:nil]; + } + return self; +} + +- (void)dealloc +{ + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +- (void)onWebViewRegistered:(NSNotification *)notification +{ + id key = notification.object; + if (_webViewKey != nil && [key isKindOfClass:[NSString class]] && [(NSString *)key isEqualToString:_webViewKey]) { + [self borrowWebView]; + } +} + +- (WKWebView * _Nullable)mappedWebView +{ + if (_webViewKey == nil) { + return nil; + } + NSMutableDictionary *sharedWKWebViewDictionary = [[RNCWKWebViewMapManager sharedManager] sharedWKWebViewDictionary]; + id webView = sharedWKWebViewDictionary[_webViewKey]; + return [webView isKindOfClass:[WKWebView class]] ? (WKWebView *)webView : nil; +} + +/** + * Borrow the shared WKWebView for our key and show it here. This only moves the view into our hierarchy; it never + * touches the WebView's configuration, its navigation/UI delegates, or its script message handlers. Those stay + * owned by the RNCWebView that built it, so the RPC socket and load callbacks keep flowing to that builder while + * we display the view. The autoresizing mask makes the WebView track our bounds, so it reflows to whatever size + * this target is laid out at — no fixed sizing anywhere. If no WebView exists for this key yet (its builder hasn't + * registered one), there's nothing to show; we pick it up from the registration notification once it appears. + */ +- (void)borrowWebView +{ + if (self.window == nil) { + return; + } + WKWebView *wkWebView = [self mappedWebView]; + if (wkWebView == nil) { + return; + } + if (wkWebView.superview == self) { + return; + } + // Detach from wherever it currently lives — the builder's RNCWebView, another target, or a temporary parent — + // by reparenting. addSubview removes it from its old superview for us; we deliberately don't call the builder's + // teardown, since it isn't going away, it's just no longer the one on screen. + wkWebView.frame = self.bounds; + wkWebView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + [self addSubview:wkWebView]; +} + +- (void)setWebViewKey:(NSString *)webViewKey +{ + _webViewKey = webViewKey; + [self borrowWebView]; +} + +- (void)didMoveToWindow +{ + [super didMoveToWindow]; + [self borrowWebView]; +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; + WKWebView *wkWebView = [self mappedWebView]; + if (wkWebView != nil && wkWebView.superview == self) { + wkWebView.frame = self.bounds; + } else { + // Not showing it yet — a common case is that our builder hadn't created the view when we first mounted. A + // layout pass is a cheap, natural point to try again (the registration notification is the primary path). + [self borrowWebView]; + } +} + +/** + * When this target unmounts, hand the WebView back to its home (its temporary parent) so it stays alive for the + * next target, rather than being torn down with us. Only do this if we're the one currently showing it — if + * another target already stole it, its own bookkeeping owns it now. + */ +- (void)removeFromSuperview +{ + WKWebView *wkWebView = [self mappedWebView]; + if (wkWebView != nil && wkWebView.superview == self) { + UIView *home = _temporaryParentNodeTag != nil + ? [self.bridge.uiManager viewForReactTag:_temporaryParentNodeTag] + : nil; + if (home != nil) { + wkWebView.frame = home.bounds; + [home addSubview:wkWebView]; + } else { + [wkWebView removeFromSuperview]; + } + } + [super removeFromSuperview]; +} + +@end diff --git a/apple/RNCWebViewTargetManager.h b/apple/RNCWebViewTargetManager.h new file mode 100644 index 0000000000..438c15b6c3 --- /dev/null +++ b/apple/RNCWebViewTargetManager.h @@ -0,0 +1,4 @@ +#import + +@interface RNCWebViewTargetManager : RCTViewManager +@end diff --git a/apple/RNCWebViewTargetManager.m b/apple/RNCWebViewTargetManager.m new file mode 100644 index 0000000000..7fb38a59d4 --- /dev/null +++ b/apple/RNCWebViewTargetManager.m @@ -0,0 +1,18 @@ +#import "RNCWebViewTargetManager.h" +#import "RNCWebViewTarget.h" + +@implementation RNCWebViewTargetManager + +RCT_EXPORT_MODULE() + +- (UIView *)view +{ + RNCWebViewTarget *target = [RNCWebViewTarget new]; + target.bridge = self.bridge; + return target; +} + +RCT_EXPORT_VIEW_PROPERTY(webViewKey, NSString) +RCT_EXPORT_VIEW_PROPERTY(temporaryParentNodeTag, NSNumber) + +@end diff --git a/index.d.ts b/index.d.ts index 0f346bd259..7888235fdd 100644 --- a/index.d.ts +++ b/index.d.ts @@ -2,9 +2,12 @@ import { Component } from 'react'; // eslint-disable-next-line import getWebViewProxy from './lib/getWebViewProxy'; +import WebViewTarget from './lib/WebViewTarget'; import { IOSWebViewProps, AndroidWebViewProps, WindowsWebViewProps } from './lib/WebViewTypes'; +export type { WebViewTargetProps } from './lib/WebViewTarget'; + export { FileDownload, WebViewMessageEvent, WebViewNavigation } from "./lib/WebViewTypes"; export type WebViewProps = IOSWebViewProps & AndroidWebViewProps & WindowsWebViewProps; @@ -64,5 +67,5 @@ declare class WebView

extends Component { clearHistory?: () => void; } -export {WebView, getWebViewProxy}; +export {WebView, WebViewTarget, getWebViewProxy}; export default WebView; diff --git a/index.js b/index.js index ec86109069..333b72804d 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,7 @@ import WebView from './lib/WebView'; +import WebViewTarget from './lib/WebViewTarget'; import getWebViewProxy from './lib/getWebViewProxy'; -export { WebView, getWebViewProxy}; +export { WebView, WebViewTarget, getWebViewProxy}; export default WebView; diff --git a/lib/WebViewTarget.d.ts b/lib/WebViewTarget.d.ts new file mode 100644 index 0000000000..46942ff3a7 --- /dev/null +++ b/lib/WebViewTarget.d.ts @@ -0,0 +1,8 @@ +import * as React from 'react'; +import { StyleProp, ViewStyle } from 'react-native'; +export interface WebViewTargetProps { + webViewKey: string; + temporaryParentNodeTag?: number; + style?: StyleProp; +} +export default function WebViewTarget(props: WebViewTargetProps): React.ReactElement; diff --git a/lib/WebViewTarget.js b/lib/WebViewTarget.js new file mode 100644 index 0000000000..6cc80830da --- /dev/null +++ b/lib/WebViewTarget.js @@ -0,0 +1,6 @@ +import * as React from 'react'; +import { requireNativeComponent } from "react-native"; +const RNCWebViewTarget = requireNativeComponent('RNCWebViewTarget'); +export default function WebViewTarget(props) { + return React.createElement(RNCWebViewTarget, props); +} diff --git a/src/WebViewTarget.tsx b/src/WebViewTarget.tsx new file mode 100644 index 0000000000..fe9adf2043 --- /dev/null +++ b/src/WebViewTarget.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import { requireNativeComponent, StyleProp, ViewStyle } from 'react-native'; + +export interface WebViewTargetProps { + /** + * The webViewKey of a WebView that already exists — one built by a with the same key. This target + * borrows that native view and displays it here. + */ + webViewKey: string; + /** + * React tag of the view the WebView should be returned to when this target unmounts, so it survives for the + * next target instead of being torn down. Typically the shared temporary-parent placeholder. + */ + temporaryParentNodeTag?: number; + style?: StyleProp; +} + +const RNCWebViewTarget = requireNativeComponent('RNCWebViewTarget'); + +/** + * Displays a WebView that already exists and is owned elsewhere. Where builds, configures, and owns a + * native WebView (keyed by webViewKey), only borrows one by that key and reparents it here to + * show it. It never sets a source, configuration, delegates, or message handlers, so the owning keeps + * the configuration and the message/callback stream while this target merely displays the view. This lets a + * single pooled WebView be shown in different places over time — moved rather than rebuilt — with no reload. + */ +export default function WebViewTarget(props: WebViewTargetProps) { + return ; +} diff --git a/src/addOnMessageListenerWithWebViewKey.ts b/src/addOnMessageListenerWithWebViewKey.ts index 715ef8d423..5cec9f0658 100644 --- a/src/addOnMessageListenerWithWebViewKey.ts +++ b/src/addOnMessageListenerWithWebViewKey.ts @@ -1,4 +1,9 @@ import { NativeModules, Platform, EmitterSubscription, NativeEventEmitter } from "react-native"; +import { WebViewMessage } from './WebViewTypes'; + +export interface WebViewMessageWithWebViewKey extends WebViewMessage { + webViewKey: string; +} const scriptMessageEmitter = new NativeEventEmitter( Platform.select({ @@ -7,8 +12,8 @@ const scriptMessageEmitter = new NativeEventEmitter( }) ); -export default function addOnMessageListenerWithWebViewKey(webViewKey: string, listener: (event: any) => void): EmitterSubscription { - return scriptMessageEmitter.addListener('ReactNativeWebViewOnMessageWithWebViewKey', (eventData) => { +export default function addOnMessageListenerWithWebViewKey(webViewKey: string, listener: (event: WebViewMessageWithWebViewKey) => void): EmitterSubscription { + return scriptMessageEmitter.addListener('ReactNativeWebViewOnMessageWithWebViewKey', (eventData: WebViewMessageWithWebViewKey) => { if (eventData.webViewKey === webViewKey) { listener(eventData); } diff --git a/src/index.ts b/src/index.ts index f335a5234e..8531c66140 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,7 @@ import WebView from './WebView'; +import WebViewTarget from './WebViewTarget'; import getWebViewProxy from './getWebViewProxy'; -export { WebView, getWebViewProxy}; +export type { WebViewTargetProps } from './WebViewTarget'; +export { WebView, WebViewTarget, getWebViewProxy}; export default WebView;