Skip to content

Block Editor: Fix iframe widget field lifecycle and repeater TinyMCE#2308

Closed
Misplon wants to merge 45 commits into
developfrom
feature/sowb-block-editor-iframe-asset-clone
Closed

Block Editor: Fix iframe widget field lifecycle and repeater TinyMCE#2308
Misplon wants to merge 45 commits into
developfrom
feature/sowb-block-editor-iframe-asset-clone

Conversation

@Misplon
Copy link
Copy Markdown
Member

@Misplon Misplon commented Apr 30, 2026

Summary

Fixes legacy SiteOrigin widget forms inside WordPress iframe editors by restoring the iframe asset/bootstrap path and hardening field initialization after the form DOM and cloned scripts mount asynchronously.

What Changed

  • Detect the WP 6.5+ post-editor iframe (iframe[name="editor-canvas"]) as well as the Site Editor canvas when resolving widget forms.
  • Clone the required WordPress/SOWB scripts, styles, and templates into the editor iframe from the correct parent document.
  • Avoid latching failed iframe asset clone attempts so setup can retry after the iframe body exists.
  • Keep keyed form initialization and send staged iframe sowbBlockFormInit messages after form mount.
  • Remove repeater-wide iframe init broadcasts from add/open/copy flows and use targeted sowsetupformfield triggers for visible repeater fields.
  • Persist copied TinyMCE repeater editor IDs to real data-tinymce-id attributes so IDs survive cloning/replacement.
  • Reinitialize TinyMCE fields when the field says initialized but editor chrome is missing, and use safer editor teardown fallbacks.
  • Observe iframe form mutations for late TinyMCE and Date Range fields, waiting for iframe dependencies such as jQuery and Pikaday when needed.
  • Stabilize Icon picker selection coverage in the iframe.
  • Add Site Editor E2E coverage for Icon selection and repeater TinyMCE chrome in Hero/Features widgets.

Why

Iframe editors can mount widget form HTML and cloned admin scripts out of order. That left some field scripts present in the iframe but not actually initialized, and TinyMCE repeater editors after the first item could render as bare textareas because copied/replaced editors only kept transient jQuery data for their IDs.

This PR covers both layers of the fix: the original iframe asset delivery problem and the follow-up repeater field lifecycle problem from #2307.

Validation

  • node --check base/inc/fields/js/date-range-field.js
  • node --check compat/block-editor/widget-block.js
  • node --check tests/e2e/wb-form-field-site-editor.test.js
  • npx playwright test tests/e2e/wb-form-field-site-editor.test.js -g "Test the Blog widget" --project="Google Chrome" --retries=0
  • npm run test:e2e (9 passed)

Reviewer Checklist

  • WP 6.9/7.0 Site Editor: Icon widget initializes and stores the selected icon value.
  • WP 6.9/7.0 Site Editor: Blog widget Date Range picker opens and stores a selected date.
  • WP 6.9/7.0 Site Editor: Editor widget TinyMCE media flow still inserts an image.
  • WP 6.9/7.0 Site Editor: Hero and Features repeaters render full TinyMCE editor chrome for at least three items.
  • WP 6.9/7.0 post editor iframe: widget forms initialize, repeaters expand/reorder, and color/icon/media fields still work.
  • widgets.php Block Widgets screen: repeater and field initialization behavior regresses neither iframe nor non-iframe screens.
  • Browser console shows no LoadError, window.error, unhandledrejection, or sowbBlockFormInit message storm during normal add/open/copy flows.

Notes

@Misplon Misplon changed the title Block Editor: Restore widget-form interactivity in WP 6.5+ post-editor iframe Block Editor: Restore iframe widget field lifecycle May 3, 2026
@Misplon Misplon requested a review from AlexGStapleton May 3, 2026 20:01
@Misplon Misplon changed the title Block Editor: Restore iframe widget field lifecycle Block Editor: Fix iframe widget field lifecycle and repeater TinyMCE May 3, 2026
Misplon and others added 12 commits May 6, 2026 15:24
- getEligibleTinyMCEFields: filter repeater template fields and _id_
  placeholder textareas from any initialization pass
- escapeSelectorValue: CSS.escape polyfill for safe data-block selector
  building
- selectBestFormInstance: score candidate forms by connected/visible/
  aria-hidden/repeater-template; DOM-order tie-breaker (-index/1000)
- getTinyMCEFieldsFromForms: delegate multi-form case to
  selectBestFormInstance, then prefer visible forms
- resolvePostMessageForms: narrow sowbBlockFormInit target using the
  is-selected + data-block block before falling back to
  selectBestFormInstance
- sanitizeIdSegment: bracket-notation / special-char sanitizer for
  stable textarea ID generation
- resolveWpEditor: single helper that prefers wp.oldEditor over
  wp.editor; shared by all init and teardown paths
- sowbTinyMCEEventNamespace: shared namespace string for all
  document-level on/off calls
Bug fixes:
- siteEditorAddMediaOverride: null-check window.tinymce and editor
  before insertContent/save/fire to avoid crash when tinymce is absent
- hasHealthyTinyMCEEditor: use getElement/getContainer to verify the
  editor actually belongs to this field, not just that it exists
- removeStaleTinyMCEFieldState: use resolveWpEditor() instead of
  duplicating the oldEditor vs editor resolution inline
- setupTinyMCEField: guard window.top.tinyMCEPreInit before accessing
  .mceInit; wrap ed.save()/trigger('change') in if(ed); re-resolve
  wpEditor fresh in wpautopToggleField handler and visibility poll to
  avoid stale closures; use window.wp.oldEditor directly in the
  wp.editor mutation block

Lifecycle hardening:
- clearTinyMCEFieldPendingSetup: also cancel sowb-pre-init-visibility-poll,
  sowb-tinymce-init-timeout and clear the initializing lock data keys
- setupTinyMCEField: add sowb-tinymce-initializing re-entrancy lock with
  a 5 s safety timeout; generate stable IDs via sanitizeIdSegment +
  collision detection instead of Math.random(); deep-clone editorSettings
  to prevent mutation; namespace wp-before-tinymce-init and
  tinymce-editor-setup listeners per field ID so they don't accumulate
- setupTinyMCEFieldInitializer: add 250 ms pre-init visibility poll that
  clears itself when visible; guard repeater template and _id_ fields
- setupSiteEditorTinyMCEFields: accept optional $targetFields so the
  postMessage handler can scope init to the affected form's fields
- message handler: resolve target form via resolvePostMessageForms /
  getTinyMCEFieldsFromForms; guard with sowbTinyMCEMessageListenerBound
  to prevent duplicate listener registration; namespace on/off calls
- Remove setupTinyMCERenderWatchdog (was a no-op poll)
admin.js:
- triggerVisibleRepeaterVisibilityFieldSetup: collect all matching
  fields (visible + hidden) before filtering; fire sowsetupformfield
  only on visible fields; always trigger sowrepeaterfieldsadded on
  document with all fields + clientId so iframe listeners can bind
  pre-init handlers before fields become visible

widget-block.js:
- initializeFormFieldsInIframe: replace burst of 5 scheduled
  postMessages with a single immediate send + persistent
  sowrepeaterfieldsadded listener; listener is namespaced per
  clientId and filters out events from other blocks via originClientId
  comparison; cleanup function removes the listener on unmount
- iframeFormInitKeyRef fingerprint: sample both ends of widgetFormHtml
  (length + slice(0,32) + slice(-32)) instead of storing the full blob
- sowbGetBlockForm / formSelector: use compound selector
  .siteorigin-widget-form.siteorigin-widget-form-main to exclude the
  React wrapper div which only carries siteorigin-widget-form-main
- sowbSetupWidgetForm: trigger sowsetupform on the resolved form after
  initialization so iframe field scripts are notified
```> admin.js:
- triggerVisibleRepeaterVisibilityFieldSetup: collect all matching
  fields (visible + hidden) before filtering; fire sowsetupformfield
  only on visible fields; always trigger sowrepeaterfieldsadded on
  document with all fields + clientId so iframe listeners can bind
  pre-init handlers before fields become visible

widget-block.js:
- initializeFormFieldsInIframe: replace burst of 5 scheduled
  postMessages with a single immediate send + persistent
  sowrepeaterfieldsadded listener; listener is namespaced per
  clientId and filters out events from other blocks via originClientId
  comparison; cleanup function removes the listener on unmount
- iframeFormInitKeyRef fingerprint: sample both ends of widgetFormHtml
  (length + slice(0,32) + slice(-32)) instead of storing the full blob
- sowbGetBlockForm / formSelector: use compound selector
  .siteorigin-widget-form.siteorigin-widget-form-main to exclude the
  React wrapper div which only carries siteorigin-widget-form-main
- sowbSetupWidgetForm: trigger sowsetupform on the resolved form after
  initialization so iframe field scripts are notified
…omments

Fix several issues and add inline comments throughout
tinymce-field.js and widget-block.js to make the non-obvious behaviour
easier to understand for future readers — human or LLM.

tinymce-field.js:
- resolvePostMessageForms: remove dead `is-selected`/`data-block`/
  `aria-selected` selectors. These attributes belong to the parent
  document's block list and are never present in the iframe DOM where
  this function runs; the fallback selectBestFormInstance path was
  already sufficient.
- setupTinyMCEField: add comments explaining the two
  clearTinyMCEFieldPendingSetup calls — the first tears down a previous
  unhealthy init inside the data-initialized branch; the second
  unconditionally clears any lingering pre-init state.
- setupTinyMCEFieldInitializer: clear data-initialized when the editor
  is unhealthy before falling through to re-init. Previously leaving it
  set caused setupTinyMCEField to attempt teardown of an editor that no
  longer exists.
- sowsetupform iframe listener: replace filter().add(find()) with a
  plain find(). The form wrapper element is never itself a TinyMCE
  field, so the filter() was unnecessary and could produce duplicates.
- Drop the $( function() ) jQuery-ready wrapper around the initial
  setupSiteEditorTinyMCEFields() call inside the frameElement branch —
  the script already runs after the iframe DOM is ready.

widget-block.js:
- initializeFormFieldsInIframe: add a comment that the message object
  is intentionally built once from stable closure values and reused
  across all sendInitMessage() calls.
- iframeFFix several correctness issues and add LLM-oriented inline comments
throughout tinymce-field.js and widget-block.js to make non-obvious
behaviour easier to understand for future readers — human or LLM.

tinymce-field.js:
- resolvePostMessageForms: remove dead `is-selected`/`data-block`/
  `aria-selected` selectors. These attributes belong to the parent
  document's block list and are never present in the iframe DOM where
  this function runs; the fallback selectBestFormInstance path was
  already sufficient.
- resolvePostMessageForms: update JSDoc to match the simplified
  implementation — remove the stale clientId param and explain why
  clientId-based narrowing is not done inside the iframe.
- escapeSelectorValue: remove entirely. Its only call site was the
  is-selected/data-block selector block that was removed above; no
  dynamic CSS selectors are built from clientId anywhere in this file.
- setupTinyMCEField: move `$field.attr('data-initialized', true)` to
  after the resolveWpEditor() null-check. Previously a transient null
  wpEditor caused an early return that left the attr set, forcing the
  next call into an unnecessary unhealthy-editor teardown cycle.
- setupTinyMCEField: add comments explaining the two
  clearTinyMCEFieldPendingSetup calls — the first tears down a previous
  unhealthy init inside the data-initialized branch; the second
  unconditionally clears any lingering pre-init state.
- setupTinyMCEField: namespace the media button click handler with
  `.sowbMedia` so that reinit does not accumulate duplicate listeners on
  the same button element.
- setupTinyMCEField: namespace the $wpautopToggleField change handler
  with fieldEventNamespace. The previous bare .off('change') silently
  removed all change listeners on that checkbox, not just ours.
- setupTinyMCEField: in the 5 s safety timeout, also remove the
  `tinymce-editor-setup` listener from window.top.document. Without
  this, fields whose TinyMCE init event never fires (e.g. removed from
  DOM) permanently leak a listener on the top-level document.
- setupTinyMCEFieldInitializer: clear data-initialized when the editor
  is unhealthy before falling through to re-init. Previously leaving it
  set caused setupTinyMCEField to attempt teardown of an editor that no
  longer exists.
- sowsetupform iframe listener: replace filter().add(find()) with a
  plain find(). The form wrapper element is never itself a TinyMCE
  field, so the filter() was unnecessary and could produce duplicates.
- Drop the $( function() ) jQuery-ready wrapper around the initial
  setupSiteEditorTinyMCEFields() call inside the frameElement branch —
  the script already runs after the iframe DOM is ready.

widget-block.js:
- initializeFormFieldsInIframe: add a comment that the message object
  is intentionally built once from stable closure values and reused
  across all sendInitMessage() calls.
- initializeFormFieldsInIframe: add a comment explaining that $fields
  passed with sowrepeaterfieldsadded is intentionally unused — admin.js
  provides it for other listeners, but here a full-form postMessage is
  sent rather than targeting individual fields.
- iframeFormInitKeyRef effect: add a comment explaining that the ref is
  deliberately not updated when initializeFormFieldsInIframe returns
  null (iframe not yet reachable), so the effect retries on the next
  render cycle rather than skipping it permanently.

The inline comments are written to give sufficient context for LLMs
making future changes to understand why the code is structured the way
it is, reducing the risk of well-intentioned but incorrect simplification.ormInitKeyRef effect: add a comment explaining that the ref is
  deliberately not updated when initializeFormFieldsInIframe returns
  null (iframe not yet reachable), so the effect retries on the next
  render cycle rather than skipping it permanently.

The inline comments are written to give sufficient context for LLMs
making future changes to understand why the code is structured the way
it is, reducing the risk of well-intentioned but incorrect simplification.
…cross-frame event routing

widget-block.js
Remove the sowsetupform jQuery trigger from sowbSetupWidgetForm. It fired on the parent document but the listener is bound to the iframe's document, making it a cross-window no-op. TinyMCE init in the iframe is already handled via the sowbBlockFormInit postMessage pathway.

tinymce-field.js
Pre-init listener/poll race fix: Introduce a per-field preInitEventNamespace (using a new dedicated _tinymcePreInitSeq counter) and store it as sowb-pre-init-namespace jQuery data on the field. The visibility poll now cancels the sowsetupformfield listener when it fires first, and vice-versa, preventing both paths from racing into setupTinyMCEField on the same field.
Pre-init listener leak fix: clearTinyMCEFieldPendingSetup now reads sowb-pre-init-namespace back from the field and calls $field.off() with the stored namespace, so the listener is always cancelled even when setupTinyMCEField is called directly (e.g. from sortStopEvent) rather than through the pre-init path.
Split _tinymceIdSeq: The single counter is replaced by _tinymceDomIdSeq (TinyMCE textarea DOM IDs) and _tinymcePreInitSeq (pre-init event namespaces), keeping the two concerns independent.
sowbBlockFormInit message handler: Replace the window.sowbTinyMCEMessageListenerBound boolean guard with a stored handler reference on window._sowbTinyMCEMessageHandler, allowing the previous handler to be removed via removeEventListener before registering a fresh one (correct for HMR/re-evaluation; the boolean approach accumulated duplicate handlers).
sowsetupform iframe listener: Bail early when $form is absent or empty instead of passing null to setupSiteEditorTinyMCEFields, preventing spurious re-initialization of all fields in the iframe on stale events.
selectBestFormInstance connected signal: Replace document.documentElement.contains(this) with this.ownerDocument.documentElement.contains(this) so the signal is accurate in both the parent-window call path (where this is an iframe-DOM element and the closure document is the wrong document) and the iframe call path.
setupTinyMCEFieldInitializer undefined ID guard: When getTinyMCEFieldEditor cannot resolve a textarea ID, treat data-initialized as stale immediately rather than forwarding undefined to tinymce.get().

admin.js
sowrepeaterfieldsadded payload hygiene: Filter repeater item template rows (.siteorigin-widget-field-repeater-item-html descendants) out of $allFields before dispatching the event, so no consumer ever receives placeholder _id_ rows.
Fix missing semicolon after the triggerVisibleRepeaterVisibilityFieldSetup arrow function.
@AlexGStapleton
Copy link
Copy Markdown
Member

I came across a number of issues when testing with this PR during testing and when investigating #2310. I've submitted a follow up PR #2311 that resolves a bulk of the issues I came across related to this PR.

AlexGStapleton and others added 17 commits May 12, 2026 15:13
Adds a module-level _tinymceInitPending map that tracks editors whose
TinyMCE init event has not yet fired. An entry is created when
sowb-tinymce-initializing is set and removed (promise resolved) as soon
as the init event fires, or after the existing 5 s safety timeout —
whichever comes first. The map therefore never grows unboundedly and adds
no overhead to already-initialised editors.

Exposes window.sowbGetTinyMCEInitPromise(editorId) outside the IIFE.
Returns a Promise<void> that resolves immediately for a ready editor, or
waits for init completion for one that is still in-flight. The function
is retrieved via the field element's ownerDocument.defaultView so that
the correct per-frame map is consulted in Site Editor iframe contexts.

This is the producer side of the TinyMCE flush-race fix. The consumer
side (making the save-bridge TinyMCE flusher await this promise before
calling editor.save()) lives in feature/wb-direct-block-save-bridge and
requires this commit to be present before it is effective.
The _tinymceInitPending[id] entry was constructed using a shared let _resolveInitPending variable in the enclosing function scope. The Promise constructor assigned to it asynchronously, and the .resolve() method on the entry called the variable rather than the captured value. If two editor instances began initialising concurrently, the second assignment would overwrite _resolveInitPending before the first entry's .resolve() had a chance to close over its own value — causing the first editor's pending promise to be resolved (and deleted) by the second editor's resolver instead.

To fix this, the shared-variable pattern was replaced with an IIFE that creates a dedicated storedResolve binding per entry. Each entry now closes over its own resolver; concurrent initialisations can no longer interfere.
…ug fixes

Widget forms rendered inside the Site Editor's editor-canvas iframe were
not correctly initialized due to several compounding issues.

compat/block-editor/widget-block.js:

- Wrong jQuery instance in sowbSetupWidgetForm. The form node returned by
  sowbGetBlockForm was wrapped in the parent-document jQuery, but
  sowSetupForm(), wpColorPicker, and all field plugins register against the
  iframe's jQuery. Fixed by re-wrapping the form node via sowbGetElementWindow
  and using the resulting formJQuery/formForms instances throughout.

- Wrong form node selected by sowbGetBlockForm. The PHP form() method renders
  its own .siteorigin-widget-form-main[data-class] element inside the React
  outer wrapper (also .siteorigin-widget-form-main). The outer wrapper has no
  direct .siteorigin-widget-field children, so sowSetupForm found 0 fields —
  breaking sections, repeaters, color pickers, and all other field setup.
  Fixed by narrowing the selector to [data-class], with .first() for safety.

- Duplicate change handlers. Multiple React render cycles could call
  sowbSetupWidgetForm for the same block, stacking unbounded .on('change')
  listeners. Fixed by namespacing with clientId and calling .off() first.

- Added sowbGetEditorCanvasFrame() helper to centralise iframe resolution
  (Site Editor canvas, post-editor canvas, window.frameElement).

- Removed window.frameElement guard from the IIFE's sowbMaybeSetupSiteEditorAssets
  call — the function resolves the correct frame internally.

base/inc/fields/js/tinymce-field.js:

- _tinymceInitPending entry stranded on cleanup. clearTinyMCEFieldPendingSetup
  cancelled the 5 s safety timeout — the only fallback resolver — without
  resolving the associated promise, leaving sowbGetTinyMCEInitPromise() waiting
  indefinitely. Fixed by resolving and deleting the pending entry before wiping
  sowb-tinymce-initializing-id.

- data-initialized cleared during an in-flight init. setupTinyMCEFieldInitializer
  checked hasHealthyTinyMCEEditor (returns false while TinyMCE is still
  initializing), cleared data-initialized, then returned immediately because
  sowb-tinymce-initializing was set. When the original init completed the field
  was left unmarked, causing the next setup event to tear down a healthy editor.
  Fixed by returning early when sowb-tinymce-initializing is already set.

- sowbBlockFormInit postMessage handler simplified. The sowSetupForm call
  previously added to the handler is removed; sowbSetupWidgetForm already runs it
  with the correct jQuery instance before the postMessage is ever sent. The handler
  now covers only TinyMCE field initialization.
…acy-handler

Block Editor: fix direct widget preview and save persistence
…rame-asset-clone-no-polling

Feature/sowb block editor iframe asset clone no polling
@AlexGStapleton
Copy link
Copy Markdown
Member

Manually merged into #2313

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.

Block Editor: TinyMCE editor field renders only in first repeater item inside post-editor iframe

2 participants