Skip to content

fix(ios): resolve PLYPresentationView blank-on-reopen and freeze on iOS#221

Merged
kherembourg merged 6 commits intomainfrom
fix/ios-ply-presentation-view-bugs
Mar 3, 2026
Merged

fix(ios): resolve PLYPresentationView blank-on-reopen and freeze on iOS#221
kherembourg merged 6 commits intomainfrom
fix/ios-ply-presentation-view-bugs

Conversation

@kherembourg
Copy link
Copy Markdown
Contributor

@kherembourg kherembourg commented Feb 26, 2026

Context

This PR fixes two iOS bugs reported by a client currently under active investigation. The issues affect the PLYPresentationView embedded paywall component when the same PLYPresentation object is reused across multiple navigations — which is the standard pattern when a presentation is fetched once and passed via the presentation prop.

Notion ticket: https://www.notion.so/313b887cdced812382b9ec0e931707f2

Bugs Fixed

Bug 1 — Blank paywall on second visit (iOS)

When PLYPresentationView is used with the presentation prop (no placementId prop), the native iOS layer consumes the presentation from presentationsLoaded on first display. On the second visit, the same presentation object is passed but is no longer in presentationsLoaded. The guard fails and falls back to createNativeViewController(placementId: nil) — returning nil — resulting in a blank view.

Root cause: getPresentationController used placementId (the prop, always nil in this usage pattern) as the fallback, instead of presentation?.placementId.

Fix: Capture effectivePlacementId = placementId ?? presentation?.placementId before the guard, so the fallback correctly recreates the view controller using the presentation's own placement ID.

Bug 2 — App freeze (~2/3 of the time) on onPresentationClosed

Sentry reported "application hanging for more than 2 seconds" triggered by a dispatch_semaphore_wait on the main thread in PurchaselyRN.m, causing a deadlock when the completion block was dispatched back to the main queue.

Fix: Replaced the semaphore pattern with an async completion-based approach (buildResultForFetchPresentation:completion:).

Additional P1 fixes (found in code review)

  • Fixed logical operator bug: || PLYProductViewControllerResultRestored|| result == PLYProductViewControllerResultRestored
  • Wrapped finalize() in dispatch_async(dispatch_get_main_queue(), ...) to prevent UI updates off the main thread
  • Added a serial dictQueue to protect concurrent writes to resultDict
  • Fixed [weak self] capture in prefetchPresentationViewController to prevent retain cycle
  • onPresentationClosed now correctly forwards resolve to the current view after it's set

Files Changed

File Change
packages/purchasely/ios/PurchaselyView.swift Use effectivePlacementId fallback; clean up old view/controller in setupView()
packages/purchasely/ios/PurchaselyViewManager.swift Forward resolve to current view in onPresentationClosed
packages/purchasely/ios/PurchaselyRN.m Replace semaphore deadlock; fix logical operator; main queue dispatch; serial dict queue
packages/purchasely/src/components/PLYPresentationView.tsx Add cancelled flag in useEffect to prevent stale async callbacks
example/src/Home.tsx Fetch presentation once at Home level and pass via nav params (reproduces client scenario)
example/src/Paywall.tsx Read presentation from route.params; add Back button for testing

Test Plan

  • Verified bug reproduces on main with the updated example (presentation fetched once, reused across visits)
  • Verified fix resolves blank paywall on second visit on iOS simulator
  • Android not affected — PurchaselyViewManager.kt does not consume/remove presentations from presentationsLoaded
  • Client to validate on their app before release

Notes

  • Fix targets iOS only; Android behavior confirmed correct
  • Example app changes intentionally kept — they replicate the real-world client usage pattern and are useful for regression testing
  • Release planned for beginning of next week

🤖 Investigated and fixed with Claude Code

@gemini-code-assist
Copy link
Copy Markdown

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request delivers critical fixes for two iOS-specific bugs affecting the PLYPresentationView component. It addresses scenarios where reusing presentation objects led to blank views and resolves an app-freezing deadlock during presentation closure. Additionally, several related stability and correctness improvements were implemented to enhance the overall robustness of the iOS integration.

Highlights

  • Blank Paywall on Second Visit (iOS): Resolved an issue where PLYPresentationView would appear blank on subsequent visits when reusing a PLYPresentation object. The fix ensures the correct placementId is used to recreate the view controller.
  • App Freeze on onPresentationClosed (iOS): Addressed an app freeze issue caused by a dispatch_semaphore_wait deadlock on the main thread. The semaphore pattern was replaced with an async completion-based approach.
  • Logical Operator Fix: Corrected a logical operator bug in PurchaselyRN.m from || PLYProductViewControllerResultRestored to || result == PLYProductViewControllerResultRestored.
  • Main Thread Dispatch for UI Updates: Ensured that UI updates related to presentation finalization are dispatched to the main queue to prevent off-main-thread modifications.
  • Concurrent Metadata Writes Protection: Implemented a serial dictQueue to protect against concurrent writes to the resultDict when processing presentation metadata.
  • Retain Cycle Prevention: Fixed a potential retain cycle in prefetchPresentationViewController by using [weak self] capture.
  • Stale Async Callback Prevention: Added a cancelled flag in PLYPresentationView.tsx to prevent onPresentationClosed from being called on unmounted components or stale callbacks.
Changelog
  • example/src/Home.tsx
    • Added useEffect and useRef to fetch and cache a PurchaselyPresentation object once.
    • Modified navigation to pass the cached presentation object to the Paywall screen as a route parameter.
  • example/src/Paywall.tsx
    • Removed local presentation fetching logic (useEffect and useState).
    • Updated to receive the PurchaselyPresentation object directly from route.params.
    • Added a 'Back' button for easier navigation during testing.
  • packages/purchasely/ios/PurchaselyRN.m
    • Corrected a logical operator in productViewResult handling.
    • Refactored resultDictionaryForFetchPresentation to buildResultForFetchPresentation:completion: to use an async completion block, removing a semaphore-based pattern.
    • Introduced a serial dictQueue to manage concurrent writes to the metadata resultDict.
    • Ensured that the finalize block for presentation results is dispatched to the main queue.
    • Updated all call sites to use the new async buildResultForFetchPresentation method.
  • packages/purchasely/ios/PurchaselyView.swift
    • Implemented cleanup logic in setupView() to remove previous view and controller instances before setting up new ones.
    • Adjusted getPresentationController to correctly determine effectivePlacementId using placementId ?? presentation?.placementId.
    • Added [weak self] to the PurchaselyRN.purchaseResolve closure to prevent retain cycles.
    • Modified createNativeViewController to use guard let for placementId and added [weak self] and DispatchQueue.main.async for onPresentationClosedPromise.
  • packages/purchasely/ios/PurchaselyViewManager.swift
    • Forwarded the resolve block from onPresentationClosed to the purchaselyView?.onPresentationClosedPromise.
  • packages/purchasely/src/components/PLYPresentationView.tsx
    • Added a cancelled flag within the useEffect hook to prevent onPresentationClosed callbacks from executing after the component has unmounted or the effect has been re-evaluated.
Activity
  • The issues were reported by a client (Bayard, La Croix app) and were under active investigation.
  • The fixes were investigated and implemented with the assistance of Claude Code.
  • The client (Bayard) is expected to validate the fixes on their app before the release.
  • A release is planned for the beginning of next week.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces several important fixes for iOS, addressing a blank paywall issue on reuse and an application freeze caused by a deadlock, specifically by transitioning from synchronous semaphore usage to an asynchronous completion-based approach for fetching presentations and correcting a logical error in purchase result handling. While these changes improve stability, a critical race condition was identified in the new metadata processing logic where concurrent writes to a mutable dictionary are not fully synchronized, which could lead to application crashes. Additionally, areas for improvement include thread safety consistency and data type correctness across the native-to-JS bridge.

Comment thread packages/purchasely/ios/PurchaselyView.swift Outdated
Comment thread packages/purchasely/ios/PurchaselyRN.m Outdated
Comment thread packages/purchasely/ios/PurchaselyRN.m Outdated
@kherembourg
Copy link
Copy Markdown
Contributor Author

@codex review

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes critical iOS bugs in the PLYPresentationView component that occur when reusing the same PLYPresentation object across multiple navigation events. The primary issues addressed are: (1) blank paywall on second visit due to incorrect placement ID fallback logic, and (2) app freeze caused by semaphore-based deadlock in async metadata handling.

Changes:

  • Fixed placement ID fallback to use presentation?.placementId when placementId prop is nil
  • Replaced semaphore-based synchronization with completion-based async pattern to eliminate deadlock
  • Fixed logical operator bug in result comparison (|| PLYProductViewControllerResultRestored|| result == PLYProductViewControllerResultRestored)
  • Added proper cleanup of previous view/controller in setupView() and thread-safe dictionary updates
  • Added cancellation pattern to TypeScript useEffect to prevent stale callbacks
  • Updated example app to reproduce the client scenario (fetch once, reuse across navigations)

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
packages/purchasely/ios/PurchaselyView.swift Captures effectivePlacementId before guard to fix blank paywall on reuse; adds cleanup of previous view/controller; fixes memory leak with [weak self]
packages/purchasely/ios/PurchaselyViewManager.swift Forwards resolve to current view after it's created to handle race condition
packages/purchasely/ios/PurchaselyRN.m Replaces semaphore deadlock with async completion pattern; fixes logical operator bug; adds main queue dispatch and serial queue for thread safety; adds default case to switch statement
packages/purchasely/src/components/PLYPresentationView.tsx Adds cancellation flag to prevent stale async callbacks after unmount; wraps in try-catch for error handling
example/src/Home.tsx Fetches presentation once at mount and caches in ref for testing reuse scenario
example/src/Paywall.tsx Reads presentation from route params instead of fetching locally; adds Back button for navigation testing

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +364 to +366
dispatch_sync(dictQueue, ^{
[resultDict setObject:value forKey:key];
});
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dispatch_sync to dictQueue for non-string values is unnecessary here. Since we're still in the synchronous for-loop and no async callbacks have been dispatched yet, you can directly write to resultDict without synchronization. The dictQueue synchronization is only needed for the async string value callbacks.

Suggested change
dispatch_sync(dictQueue, ^{
[resultDict setObject:value forKey:key];
});
[resultDict setObject:value forKey:key];

Copilot uses AI. Check for mistakes.
kherembourg and others added 6 commits March 2, 2026 14:34
Bug #1 — Paywall blank after close/reopen (iOS only):
- setupView() now removes previous subview and controller before
  adding new ones, preventing stale view accumulation
- createNativeViewController() completion closure now properly
  calls onPresentationClosedPromise instead of discarding the result
- prefetchPresentationViewController() uses [weak self] capture to
  avoid retain cycles
- PurchaselyViewManager.onPresentationClosed() now forwards the
  resolve block directly to the current PurchaselyView, fixing the
  race between view() (called during render) and the useEffect that
  registers the promise resolver

Bug #2 — App freeze with onPresentationClosed (~2/3 times):
- PLYPresentationView: add cancelled flag + cleanup return in useEffect
  to prevent dangling async handleClose() calls accumulating across
  re-renders and component unmounts
- PurchaselyRN.m: replace blocking dispatch_semaphore_wait in
  resultDictionaryForFetchPresentation with a fully async
  buildResultForFetchPresentation:completion: — eliminates the
  main-thread deadlock that occurred when getStringWith:completion:
  callbacks dispatched back to the already-blocked main queue

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Fix always-true OR condition in resultDictionaryForPresentationController:
  `|| PLYProductViewControllerResultRestored` → `|| result == ...`
  (was causing hidePresentation to fire even on cancellation)
- Dispatch finalize() to main queue inside dispatch_group_notify to ensure
  resolve() is always called on the main thread (eliminates off-main-thread
  RCT bridge call risk)
- Protect concurrent NSMutableDictionary writes in metadata loop with a
  dedicated serial queue (dictQueue) — NSMutableDictionary is not thread-safe
  for concurrent mutation from SDK callbacks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…Controller

When PLYPresentationView is used with the `presentation` prop only (no
`placementId` prop), the placementId prop is nil. On the second visit,
the presentation has already been consumed from presentationsLoaded, so
the guard fails and falls back to createNativeViewController with nil —
returning an empty view.

Fix: capture effectivePlacementId = placementId ?? presentation?.placementId
before the guard, so the fallback correctly uses the presentation's own
placementId to recreate the view controller on subsequent visits.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…entationView bug

Fetch the nested presentation once on Home mount and pass it via
navigation params to Paywall, reusing the same object across visits.
This replicates the Bayard scenario (fetch once, reuse) and makes
the blank-on-reopen bug reproducible locally. Also adds a Back button
to Paywall for easier navigation during testing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Use NSNull() instead of [] for nil plan in createNativeViewController,
  matching the TypeScript PurchaselyPlan | null type on the JS side
- Synchronize non-string metadata writes through dictQueue to eliminate
  race condition between loop (main thread) and async string callbacks
- Dispatch finalize() to main queue in the no-metadata path for
  consistency with the metadata path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove no-op willMove(toParent:)/removeFromParent() calls in setupView()
  since _controller is never added as a child VC; only view cleanup needed
- Log unexpected onPresentationClosed errors in PLYPresentationView instead
  of silently swallowing them (cancellation errors remain suppressed)
- Remove debug console.log from Home.tsx example

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@kherembourg kherembourg force-pushed the fix/ios-ply-presentation-view-bugs branch from 6853f6a to 5844125 Compare March 2, 2026 13:34
@kherembourg kherembourg merged commit 9a880ba into main Mar 3, 2026
5 checks passed
@kherembourg kherembourg deleted the fix/ios-ply-presentation-view-bugs branch March 3, 2026 09:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants