Skip to content

[BUG]: Editor never becomes ready in v1.0.1 #343

@ddelange

Description

@ddelange

Describe the bug

On Fabric (New Architecture) with react-native-webview 13.15.0, the TenTap editor intermittently (and in our case, consistently) never becomes ready. useBridgeState(editor).isReady stays false permanently, editorState.empty stays true, and the WebView shows a blank page.

The root cause is a race condition between injectedJavaScriptBeforeContentLoaded and the WebView's HTML loading on Fabric. The HTML's setInterval(..., 1) polls for window.contentInjected before initializing TipTap — but on Fabric, the source prop (HTML content) can be applied before injectedJavaScriptBeforeContentLoaded executes. When this happens, window.contentInjected is never set, TipTap never initializes, and no StateUpdate/EditorReady messages are sent to React Native.

Related: #335 (same symptom, closed but the underlying Fabric timing issue is unaddressed)

Environment

  • @10play/tentap-editor: 1.0.1
  • react-native: 0.81.5 (New Architecture / Fabric enabled)
  • react-native-webview: 13.15.0 (Expo SDK 54 pin)
  • expo: ~54.0.x
  • Platform: iOS (confirmed), Android (not yet tested)

To Reproduce

  1. Use TenTap 1.0.1 with React Native 0.81.5 and Fabric enabled
  2. Render a <RichText editor={editor} /> component
  3. Observe that useBridgeState(editor).isReady stays false

This reproduces consistently in our app after upgrading from RN 0.79.5 (Old Architecture) to 0.81.5 (Fabric). It worked on every version prior to the New Architecture migration.

Root cause analysis

The initialization chain

  1. RichText.tsx passes injectedJavaScriptBeforeContentLoaded={getInjectedJSBeforeContentLoad(editor)} to the WebView
  2. getInjectedJSBeforeContentLoad() (in src/RichText/utils.ts) sets window.bridgeExtensionConfigMap, window.whiteListBridgeExtensions, window.editable, window.contentInjected = true, etc.
  3. The editor HTML contains a setInterval(() => { ... }, 1) that polls for window.contentInjected before calling the TipTap initialization code
  4. Once TipTap initializes, it posts StateUpdate and EditorReady messages back to React Native

What breaks on Fabric

On Fabric, native view creation and prop application are not guaranteed to be synchronous in the same way as the old architecture. The source prop (containing the HTML) may be applied to WKWebView before the injectedJavaScriptBeforeContentLoaded user script is registered. When this happens:

  • The HTML loads and starts executing JavaScript
  • setInterval polls for window.contentInjected — it's undefined
  • The poll continues forever, TipTap never initializes
  • No EditorReady message is ever sent

Additional complication: the key-change remount

RichText.tsx lines 149-155 force a WebView remount on iOS by changing the key prop from 'webview' to 'webview_reloaded' on first load. This was a workaround for react-native-webview#3578, which was fixed in react-native-webview 13.12.5. Since 13.15.0 already contains the fix, this remount is unnecessary and may exacerbate timing issues on Fabric by creating a second WebView instance that also needs to race injectedJavaScriptBeforeContentLoaded against HTML loading.

Workaround

We worked around this by re-injecting the initialization variables via injectJavaScript() in the onLoad callback as a fallback:

<RichText
  editor={editor}
  onLoad={() => {
    // Fallback for Fabric race condition:
    // injectedJavaScriptBeforeContentLoaded may not execute before the HTML
    // loads, leaving window.contentInjected unset and TipTap stuck.
    setTimeout(() => {
      const initJS = getInjectedJSBeforeContentLoad(editor);
      editor.injectJS(
        `if (!window.contentInjected) { ${initJS} } true;`,
      );
    }, 50);
  }}
/>

This re-injects window.contentInjected = true (and all other bridge config variables) after the WebView reports it has loaded. The if (!window.contentInjected) guard makes it a no-op when the original injection worked correctly. The HTML's setInterval(..., 1) picks up the variables on the next tick.

This works but is fragile — it depends on importing getInjectedJSBeforeContentLoad from TenTap's internal src/RichText/utils and duplicates logic that should live in RichText.tsx itself.

Suggested fix in TenTap

The fix should live inside RichText.tsx's onLoad handler. After TenTap's existing onLoad logic runs, it should call editor.webviewRef.current.injectJavaScript() with the same content as getInjectedJSBeforeContentLoad(editor), guarded by if (!window.contentInjected).

Additionally, consider removing the key-change remount workaround (lines 149-155) since react-native-webview 13.12.5+ already contains the fix for #3578, and the remount adds unnecessary complexity on Fabric.

// Inside RichText.tsx onLoad handler:
onLoad={(e) => {
  setLoaded(true);
  // Existing iOS key-change workaround (consider removing)
  if (Platform.OS === 'ios' && key === 'webview') {
    setKey('webview_reloaded');
  }
  // Fallback: re-inject init variables in case injectedJavaScriptBeforeContentLoaded
  // lost the race against HTML loading on Fabric
  setTimeout(() => {
    editor.webviewRef.current?.injectJavaScript(
      `if (!window.contentInjected) { ${getInjectedJSBeforeContentLoad(editor)} } true;`
    );
  }, 50);
  props.onLoad && props.onLoad(e);
}}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions