Skip to content

[iOS][New Arch] CPDFViewManager commands hang — view registry empty under Fabric interop #10

@shahab-caspite

Description

@shahab-caspite

Summary

On iOS, every JS→native command method on <CPDFReaderView> hangs forever (or silently no-ops) under React Native New Architecture interop (RCT_NEW_ARCH_ENABLED=1, Fabric on, Bridgeless off). pdfReaderRef.current.save() is the most visible failure mode — its Promise never resolves and never rejects, leaving any UI that awaits it stuck. The same broken code path also affects _pdfDocument.getDocumentPath(), _pdfDocument.getPageSize(), _pdfDocument.renderPage(), and ~60 other view-manager methods. Android is not affected.

Environment

  • @compdfkit_pdf_sdk/react_native@2.5.0 (also reviewed 2.6.6 source — see "Note on 2.6.6" below)
  • iOS pods: ComPDFKit (2.5.0), ComPDFKit_Tools (2.5.0)
  • React Native: 0.77.3
  • iOS deployment target: 15.1
  • Reproduced on iPhone 17 Pro Max simulator
  • New Architecture: enabled
  • Bridgeless: disabled
  • Under these flags, RCTCPDFReaderView (a legacy RCTViewManager) is hosted inside RCTLegacyViewManagerInteropComponentView.

Steps to reproduce

  1. Build an app with RCT_NEW_ARCH_ENABLED=1 and bridgelessEnabled=false on iOS.
  2. Render <CPDFReaderView ref={pdfReaderRef} ... />.
  3. Load a PDF, optionally add an annotation.
  4. Call await pdfReaderRef.current.save().

Expected: Promise resolves with true.
Actual: Promise never resolves and never rejects. JS hangs.

Root cause

RCTCPDFReaderView.cpdfViewAttached(_:) writes to a side-channel dictionary using cpdfView.reactTag.intValue as the key. Under iOS New Arch interop, the RCTCPDFView instance returned by view() is hosted inside an interop wrapper that owns the React tag JS sees via findNodeHandle(). The inner view's reactTag property is nil/0 at the point cpdfViewAttached fires, so the dictionary write is meaningless — the dictionary stays empty (or holds only a 0 key that JS will never query).

When JS calls e.g. CPDFViewManager.save(tag):

  1. RCTDocumentManager.save(tag:resolve:reject:) calls reader.saveDocument(forCPDFViewTag: tag).
  2. RCTCPDFViewManager.saveDocument(forCPDFViewTag:completionHandler:) reads cpdfViews[tag] — gets nil.
  3. rtcCPDFView?.saveDocument(...) short-circuits via optional chaining. The completion handler is silently discarded.
  4. resolve / reject are never called. The JS promise hangs.

Diagnostic logs (with temporary NSLog additions to confirm)

Added NSLog statements to ios/RCTDocumentManager.swift::save and ios/RCTCPDFViewManager.swift::saveDocument(forCPDFViewTag:). After loading a PDF, adding an annotation, and tapping save:

CPDFViewManager.save called with tag=3032
readerView resolved, cpdfViews keys=[]
saveDocument(forCPDFViewTag: 3032) — cpdfViews[tag] is NIL (all keys: [])
⚠ view lookup returned NIL — completion handler will NEVER fire, promise will hang
saveDocument call returned synchronously (waiting for completion)

Tag 3032 is also the value on every onChange event payload received by JS — confirming findNodeHandle() and event-bubbling targets are consistent. The only thing out of sync is the iOS-side cpdfViews registry.

Files involved

  • ios/RCTCPDFViewManager.swift:34-45saveDocument(forCPDFViewTag:) reads cpdfViews[tag]
  • ios/RCTCPDFViewManager.swift:820-822cpdfViewAttached(_:) writes cpdfViews[cpdfView.reactTag.intValue]
  • ios/RCTDocumentManager.swift::save(tag:resolve:reject:) — promise resolver that gets silently dropped
  • ios/RCTCPDFView.swift::createCPDFView() — call site for delegate?.cpdfViewAttached(self)

Why Android works

Android's CPDFViewModule (and analogous Java code path) dispatches commands through standard view-by-reactTag lookup in UIManagerModule rather than a parallel side-channel dictionary. We've confirmed Android saves complete in ~30ms on the same build with the same JS code.

Note on 2.6.6

I checked the v2.6.6 source. The new CPDFViewRegistry singleton with weak refs, guard let reactTag = cpdfView.reactTag early-return, cpdfViewDetached cleanup, and explicit "view_not_found" rejection are all improvements — but they don't change the underlying mechanism. There's still no Fabric component spec / codegenConfig, the registration still depends on cpdfView.reactTag, and under iOS New Arch interop that value will still be nil/0 when cpdfViewAttached fires.

The 2.6.6 behaviour under iOS New Arch would be a clean Promise rejection instead of a hang — better UX, but the annotations still aren't saved.

Suggested fix directions

Any of the following would resolve it:

  1. Defer registration until the inner RCTCPDFView has received its reactTag from interop (e.g. register on didMoveToWindow rather than during createCPDFView).
  2. Look up the view via the bridge rather than a local registry: bridge.uiManager.view(forReactTag:) returns the outer interop wrapper, walk its subview tree to find the inner RCTCPDFView. This is the lowest-risk patch — we've validated it locally and can share the diff.
  3. Add a Fabric component spec (codegenConfig in package.json + matching component source) so the library participates in Fabric natively rather than via the interop layer.

(1) and (2) are tactical. (3) is the strategically right fix and aligns with React Native's stated direction for the New Architecture.

Test plan once fixed

  • Build a sample app with RCT_NEW_ARCH_ENABLED=1, bridgelessEnabled=false.
  • Render <CPDFReaderView>, load a PDF.
  • Verify the view manager's registry is populated with a non-zero, non-nil tag that matches what findNodeHandle(viewRef) returns to JS.
  • Verify await pdfReaderRef.current.save() resolves with true after annotations are added.
  • Verify _pdfDocument.getDocumentPath(), _pdfDocument.getPageSize(...), _pdfDocument.renderPage(...) all resolve correctly.

Happy to share our minimal repro app, full Xcode console capture, and Podfile if useful. Will also be raising this via paid support — wanted to file here too so other users hitting this can find it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions