Bump version to v2.10.4 - #219
Merged
Merged
Conversation
Three defects on the inline-credential refresh path that legacy flows still use (CredentialInjector bails when connection_id is 0, so these flows never reach the connection-based refresh in OAuth2Authorization): - refreshAccessToken sent client_id, client_secret and refresh_token in the query string. A URL carrying them is recorded by access logs and forward proxies. Moved to a form-urlencoded POST body, matching what OAuth2Authorization::buildRefreshBody already does for every connection-based integration. - refreshTokenDetails stored the `false` that refreshAccessToken returns on failure, so the next line fataled on $tokenDetails->access_token under PHP 8. The stale token is kept instead, letting Salesforce return the real 401 that callers already surface as wp_send_json_error. - Seven saveRefreshedToken() calls passed $response['organizations'], a key refreshTokenDetails never returns, to a method that takes two parameters. The argument was discarded and the read emitted an undefined-array-key warning on every call. The success path is unchanged in all three cases.
Salesforce blocks the web server flow outright when a Connected App enables "Require Proof Key for Code Exchange (PKCE) Extension", which is the default for External Client Apps. Switching the declared grant to authorization_code_pkce makes Oauth2Connection attach code_challenge and code_challenge_method=S256 on authorize, then code_verifier on exchange. Apps that do not require PKCE ignore the challenge, so both configurations work. Existing integrations are unaffected: Oauth2Connection normalizes the stored grant back to authorization_code, the refresh path never reads PKCE fields, EditSalesforce does not render the authorization step, and reauthorizeConnection has no caller. Only new connections take this path.
Reverts 19cd39f. The change belongs on fix/salesforce-pkce-token-refresh, not on this branch; it was pushed here by mistake.
Reverts cf98361. The change belongs on fix/salesforce-pkce-token-refresh, not on this branch; it was pushed here by mistake.
The Tutor LMS action always ran against get_current_user_id(), so it only worked for logged-in users. resolveUserId() now reads the flow's userSource: when it is "email", the mapped user_email value is looked up with get_user_by(), and an invalid or unknown email returns a WP_Error that is logged and aborts the action. Flows saved before this change carry no userSource, so they keep running against the logged-in user. enrollCourse(), completeLesson(), completeCourse() and resetCourse() now take the resolved user id instead of looking it up themselves.
Adds the user_email field definition, a generator for its field_map entry, and the mapped/incomplete checks used to gate saving. TutorLmsFieldMap renders the single locked user_email row with form field, custom value and smart code sources, matching the field map used by the other LMS actions.
The integration layout gains a "Run Action For" select. Choosing "User Matched by Email" reveals the user email field map and swaps the note to explain that the user must already exist; the default stays on the logged-in user so existing integrations render unchanged. The wizard's Next button and the edit screen's Update button are disabled while the email source is selected without a mapped email, and the edit screen now passes form fields down to the layout.
Tutor LMS grew a userSource toggle that resolves the action's target user from a mapped email. The same limitation exists across the other WordPress plugin actions, so the pieces move somewhere reusable before being ported. ActionUser::resolve() takes the flow details, the trigger values and the field_map property an integration uses for its action-side field name, and returns either a user id or a WP_Error. On the frontend, UserSourceSelect and UserEmailFieldMap render the picker and the single locked email row, with the matching state helpers in userSource.js. Tutor LMS now uses those instead of its own copies; behaviour is unchanged.
Enroll and unroll ran against get_current_user_id(), so a flow triggered by a webhook or a guest submission acted on user 0. The controller now resolves the target through ActionUser, and the layout offers the logged-in / mapped-email choice with the email field map behind it. Flows without userSource keep running against the logged-in user.
Most actions already use field_map for their own payload, so adding a user_email row there would collide with the integration's own mapping UI and its required-row indexing. The mapped email now lives in its own flow_details.userEmailField object, which keeps the shared picker independent of whatever an integration does with field_map. ActionUser::resolve() drops its field_map key argument and reads userEmailField instead; UserEmailFieldMap renders that single row. Tutor LMS and WP Courseware move over.
Adding a commission looked the affiliate up from get_current_user_id(), so a flow without a logged-in user found no affiliate and silently dropped the commission. The target user now comes from ActionUser, with the picker and email field map in the layout.
Both referral actions stamped the referral with get_current_user_id() and, for "create a referral for the user", derived the affiliate from it too. They now take the user resolved by ActionUser. get_user_by() returning false is also handled: the previous code dereferenced user_login unconditionally, which fatals whenever the resolved id is 0.
Both membership integrations resolved their target with get_current_user_id(), so a guest-triggered flow wrote a transaction or a membership level against user 0. They now take the id ActionUser resolves and offer the logged-in / mapped-email choice. Paid Memberships Pro's helper had no access to the trigger values, so the controller resolves the user and passes it into execute().
Adding to and removing from a level both ran against get_current_user_id(), so a guest-triggered flow created an RCP customer for user 0. The target now comes from ActionUser. Note the integration's own field map is still collected and never read by insertMember(); that is a separate defect and is left alone here.
All six award and revoke actions targeted get_current_user_id(). They now take the user ActionUser resolves. The achievement award also passed the current user as the awarding admin, which is now the same resolved id rather than a second independent lookup.
Same shape as Tutor LMS: all five actions ran against get_current_user_id(). The controller resolves the target through ActionUser and threads it into enroll, complete lesson, complete course and reset course.
The Pro actions already targeted a mapped user_email while the five free ones resolved to get_current_user_id(), so one dropdown behaved two different ways. The free actions now offer the same logged-in / mapped-email choice, resolved through ActionUser.
Six of the seven actions resolved their target with get_current_user_id() and now take the id ActionUser returns. "Unenroll user from a membership" is left as it is: it assigns a fixed user id rather than looking one up, so it needs its own fix and the picker is hidden for that action.
Fourteen of the seventeen actions resolved their target with get_current_user_id(). Each method now takes the id ActionUser returns, and the layout offers the logged-in / mapped-email choice. Two actions are excluded from the picker: "send an email to the users group leaders" has no user target, and "unenroll the user from a course" assigns a fixed user id rather than looking one up, so it needs its own fix first.
…ctions "Unenroll the user from a course" assigned $user_id = 5 and "Unenroll user from a membership" assigned $user_id = 30, so both acted on whoever those ids belong to on the site rather than on the flow's user. Both now take the id ActionUser resolves, like the rest of their actions, and the user source picker is no longer hidden for them. Two guards that the hardcoded values had masked: - LifterLMS bailed on `!function_exists(...) && empty($user_id) && empty($membershipId)`, which a non-empty hardcoded id made unreachable, so a missing llms_unenroll_student() would have been a fatal. It is `||` now. - LearnDash returned $apiResponse without initialising it, which is undefined when 'any' is chosen and the user has no enrolled courses.
getAllList made a single GET to /addressbooks, so accounts with more address books than the API's per-request cap only ever saw the first page in the list dropdown. Loop with limit/offset until a page returns fewer than the limit, and bail out on a WP_Error or non-array response instead of iterating over it.
The list dropdown reverted to its previous value whenever the field fetch failed: handleInput put listId on a local copy of the config and left the commit to refreshSendPulseHeader, which only calls the setter inside its success branch. Selecting the empty option was ignored entirely. Commit listId right away with a functional update, clear the previous list's cached fields and mappings, and only fetch when a list is actually selected. Both refresh helpers captured the config at call time and wrote the whole snapshot back once the response arrived, discarding anything the user changed while the request was in flight. They now apply the response onto the latest state via mutative. Neither helper set isLoading, so the loader never appeared and the refresh buttons never disabled, which let repeated clicks race each other. They set it now. The field map handlers shallow-copied the config and then spliced or assigned into field_map, mutating the array and row objects still held in state. In the edit screen that state is the $actionConf Recoil atom. Use the shared mutative-based FieldMapHelper handlers instead, plus a matching handleCustomValue. Also default listId and customValue to an empty string so the inputs stay controlled on first render.
Any notice, warning or deprecation printed before wp_send_json_* lands in
the admin-ajax body ahead of the payload, so JSON.parse throws. The catch
branch tried to salvage it with /{"success":(?:[^{}]*)*}/, but [^{}]
excludes braces, so that pattern can only match an object with no nested
object. Every response here is nested, so the salvage always failed and
the caller got {success: false} with the raw html as data. Components read
that as a permanent failure and left their loading state on forever. The
nested quantifier also backtracked catastrophically on long bodies.
Scan for the first balanced top-level value instead, skipping braces that
appear inside string literals, and retry from the next candidate when the
prefix text itself contained one. Handles prefixes, suffixes and both.
fetch() also rejects on abort and on network failure, and nothing caught
that, so the rejection escaped as an unhandled rejection and stranded the
caller the same way. Return a value in both cases, flagging aborts so the
polling loops can tell them apart from a genuine empty response.
KonnectzIT saves flow_details.type as 'konnectzIT' with a lowercase k, but the edit switch only listed 'KonnectzIT'. Opening a saved KonnectzIT integration fell through to the default branch, which renders a loader, so the settings screen span forever and the action could never be edited.
There was no error boundary anywhere in the app, so a throw inside any one integration component tore the whole tree down and left an empty page with no message and nothing pointing at the cause. Wrap the route tree, the new action screen and the edit screen, keyed so navigating away clears the caught error. Both action switches also failed silently when a name did not match a case. New action screens returned an empty fragment and edit screens returned a loader, which reads as a blank page and an endless spinner respectively. Say which action has no screen, and mention Pro on the edit path since a deactivated Pro plugin is the common way to reach it.
The method dropdown is uncontrolled, so a user who filled in a url and a body but never opened it saved no actionMethod at all. execute() then fell back to 'get', and WP puts a GET body in the query string, so the request arrived at the receiving end as a GET with nothing in it. Seed POST on new actions and key the dropdown so it shows the saved value once the config loads on the edit screen. Saved flows keep falling back to GET rather than changing behaviour under them, but now log why, naming the dropped body so the cause is visible from the log instead of only from the receiving end. processPayload also read $details->body->type with no guard, warning on every run for an action whose body tab was never opened.
The eight fetch loops re-fired the moment a poll came back empty, with no delay, so waiting for a test submission sent back-to-back requests to admin-ajax for the full three minute window. Wait FETCH_RETRY_DELAY between polls, and do not retry a request the user aborted by pressing Stop. The try/catch around each loop is synchronous and never saw a rejected promise, so a throw inside the .then body skipped stopFetching() and left the button spinning until the countdown expired. Give the chains a catch, and let Webhook's own catch clear the state it was missing.
A mapped field the trigger did not send resolved to null and was still put
in the payload, so Brevo got {"email":null,"attributes":{"FIRSTNAME":null}}
and rejected the whole request instead of ignoring the empty parts. Leave
absent values out; a field that is present but blank still goes through as
an empty string.
Fail with a clear message when the email itself resolves to nothing, since
Brevo cannot create a contact without one and the raw api error did not say
which field was at fault.
The email also went into the lookup and update urls unencoded, so a plus
addressed contact was never matched and got duplicated on every run.
custom_fields entries resolved to null for any field the trigger did not send, and Asana rejects the whole task rather than skipping them, so tasks never got created and the log only showed nulls. Leave absent values out. The project, section and custom field fetchers each made one unpaginated call. Asana caps a page and returns next_page.offset, so accounts past the first page only ever saw part of their projects and the one they wanted looked missing. Walk the offsets instead, bounded at MAX_PAGES. addTask returned nothing when the api failed and a section was configured, so the caller logged a null response instead of the error. The result check also read ->data and ->status off responses that can be an array or a WP_Error, warning on every failed run. Absent request parameters are now rejected up front rather than interpolated into the url as empty strings.
The method dropdown is uncontrolled, so a user who filled in a url and a body but never opened it saved no actionMethod at all. execute() then fell back to 'get', and WP puts a GET body in the query string, so the request arrived at the receiving end as a GET with nothing in it. Seed POST on new actions and key the dropdown so it shows the saved value once the config loads on the edit screen. Saved flows keep falling back to GET rather than changing behaviour under them, but now log why, naming the dropped body so the cause is visible from the log instead of only from the receiving end. processPayload also read $details->body->type with no guard, warning on every run for an action whose body tab was never opened.
The eight fetch loops re-fired the moment a poll came back empty, with no delay, so waiting for a test submission sent back-to-back requests to admin-ajax for the full three minute window. Wait FETCH_RETRY_DELAY between polls, and do not retry a request the user aborted by pressing Stop. The try/catch around each loop is synchronous and never saw a rejected promise, so a throw inside the .then body skipped stopFetching() and left the button spinning until the countdown expired. Give the chains a catch, and let Webhook's own catch clear the state it was missing.
A mapped field the trigger did not send resolved to null and was still put
in the payload, so Brevo got {"email":null,"attributes":{"FIRSTNAME":null}}
and rejected the whole request instead of ignoring the empty parts. Leave
absent values out; a field that is present but blank still goes through as
an empty string.
Fail with a clear message when the email itself resolves to nothing, since
Brevo cannot create a contact without one and the raw api error did not say
which field was at fault.
The email also went into the lookup and update urls unencoded, so a plus
addressed contact was never matched and got duplicated on every run.
custom_fields entries resolved to null for any field the trigger did not send, and Asana rejects the whole task rather than skipping them, so tasks never got created and the log only showed nulls. Leave absent values out. The project, section and custom field fetchers each made one unpaginated call. Asana caps a page and returns next_page.offset, so accounts past the first page only ever saw part of their projects and the one they wanted looked missing. Walk the offsets instead, bounded at MAX_PAGES. addTask returned nothing when the api failed and a section was configured, so the caller logged a null response instead of the error. The result check also read ->data and ->status off responses that can be an array or a WP_Error, warning on every failed run. Absent request parameters are now rejected up front rather than interpolated into the url as empty strings.
addContact returned nothing at all when a tag was selected: the tag branch called addTag and dropped its result on the floor. execute() then tested isset(null->errors), which is false, so every run logged "successfully" no matter what the api answered. Return the response, and skip tagging when the contact was not created rather than tagging id null. The tag branch was also entered on isset(), which is true for a saved but empty tag, so an action with the tag cleared took the broken path too. Mapped fields the trigger did not send were read unguarded, warning on every run and putting nulls in the payload; leave them out. An empty email returned a plain array that execute() then logged as a success. Detect it and log the failure.
settype() returns whether the cast succeeded, not the cast value, so every checkbox property was written to Notion as true regardless of what the form sent. Cast the value instead. multi_select iterated the value with no array check, so a radio or single select field arrived as a scalar, warned, and the property was dropped rather than sent as its one option. number cast to int, silently truncating any decimal the form collected, and turned non numeric input into 0; send the real number and nothing at all when the value is not numeric. The response check read ->object off a value that can be a WP_Error, and the field map read properties that a saved config need not carry.
Every fetcher tested $response->response->error for failure, but Google returns error at the top level, so a 401 from an expired token passed the success check. The spreadsheet listing then read ->files off the error object, warned, and answered success with an empty list, which reached users as a spreadsheet dropdown that was simply blank with nothing explaining why. The worksheet and header fetchers read ->status and ->message off a WP_Error, which carries neither, so they always said "Unknown". Read the real shape in one place and name reauthorization as the fix on 401 and 403. refreshAccessToken returns false on any failure, including invalid_grant, and all three callers carried on with the token they already knew was expired, guaranteeing the 401 above. Stop and ask for a reconnect instead. The drive listing also requested no page size and ignored nextPageToken, so accounts past the first page could not find their spreadsheet. Walk the pages and request only the fields being used. $authorizationHeader was read before assignment when the token was still fresh, warning into the response body on every call.
The failure check read ->data[0]->status unconditionally, so a WP_Error or any response without a data array warned on every failed run and the warning went out in the ajax body. pLayout was read with no guard, warning whenever a Deals action was saved before a layout was picked. The layout list is a plain list, so uksort compared its numeric keys and never ordered the dropdown by anything visible. Sort on display_label.
Two WP_Error codes added with the earlier action fixes wrote the bit_integrations_ prefix as a literal instead of going through Config::withPrefix(), leaving the prefix with more than one definition. Also drops the inline commentary from those fixes; the pragmas and the doc blocks stay.
The google fetchers were made to stop with a reconnect message whenever refreshAccessToken returned false. That is wrong for a config whose generates_on is unset or stale but whose token still works, and it is redundant now that the error reader understands google's real shape: an expired token comes back as a 401 and already reports as a reconnect. Fall through to the request instead. The asana list builders read ->gid and ->name off each record with no guard, so an unexpected shape warned into the ajax body, which is the same failure the rest of this work is removing. Skip records with no gid and fall back to it for the label.
get_role('administrator') returns null when a membership or role plugin has
removed or renamed the role, and add_cap() on that is fatal during plugin
activation.
get_userdata() returns false for an id that no longer resolves. On customer
create that produced a warning and an empty payload; on customer delete
in_array() then received null for its haystack, which is a TypeError and so
a hard fatal while deleting a user.
The Store API checkout handler, which runs for every block checkout, read
the legacy $order->id property and built its own WC_Checkout instead of
taking the singleton, so it skipped woocommerce_checkout_init and tripped
_doing_it_wrong inside the checkout response.
Inserting a form field or smart tag from the dropdowns appended the token at the end of the document and scrolled there, instead of writing at the caret and staying put. Two causes stacked. getRange() read el.selectionStart unconditionally, but opening the select blurs the textarea, and an unfocused controlled textarea reports its caret at the end once React has replaced its value. And assigning a new value resets scrollTop and parks the caret at the end. getRange() now trusts the live caret only while the textarea has focus, and otherwise falls back to the position remembered from select, keyup, click and blur. commit() captures scrollTop before pushing the value up and the pending-selection effect restores it after setSelectionRange. The scroll restore is skipped in the one case where it would hide the result: no remembered caret at all, when the dropdown is used before the textarea has ever been clicked. The token then appends at the end and the browser is left to scroll to it. appendedAtEndRef carries that decision from getRange to commit so every toolbar button gets the same handling without touching their call sites. Also fixes the same behaviour in the Trello card description editor.
Adds a "Rich Text Description" utility to the Google Calendar action. When
enabled, the description is written in the shared MarkdownEditor instead of
being mapped from a plain text form field, and description is dropped from
the Field Map so it cannot be set twice.
Google Calendar renders HTML in event descriptions, not Markdown, so raw
Markdown would show literal ** and _ in the event. RecordApiHelper converts
the stored Markdown to the small HTML subset Google keeps: headings become
a bold paragraph, quotes and code blocks become paragraphs, since h1-h6,
blockquote and pre are all dropped on their side.
The pipeline is convert, then substitute, then wp_kses. Converting before
substitution keeps a form value containing * or _ from injecting formatting;
running kses after it keeps a value containing markup out of the payload and
the log. Smart tags are shielded behind placeholders during the inline pass
so an underscore in ${first_name} is not read as emphasis, and link targets
are restricted to http, https, mailto and tel.
descRichText is passed as a defaulted argument to executeRecordApi, so
existing saved flows are unaffected.
Google Calendar saves now go through the sanitize_post_content route, which
leaves the Markdown punctuation intact.
Moves the Markdown to HTML conversion out of the free plugin. RecordApiHelper
now fires a filter and uses whatever comes back, instead of converting inline:
$description = Hooks::apply(
Config::withPrefix('googlecalendar_rich_description'),
null,
$descRichText,
$fieldValues
);
The null default is load bearing. With Pro inactive nothing binds the filter,
so the description is left unset rather than sent to Google as literal
Markdown, which is what a free-side fallback conversion would have produced.
Drops markdownToHtml(), inlineMarkdownToHtml() and sanitizeDescription() from
this file; they move to the Pro plugin along with the wp_kses allowlist. The
substitution of form values into the converted HTML moves with them, so the
convert, then substitute, then kses ordering is preserved on the Pro side.
The toggle is what gets gated, not just the editor. Enabling it strips description from the Field Map, so a free user able to flip it would lose the plain text mapping and get nothing in its place. It now carries input-disable and opens ProModal instead, using the same class this file already applies to the all-day and slot-check exclusion. The editor itself is wrapped in ActionProFeatureComponent as well, which covers a flow that already has richTextDesc saved when Pro is later deactivated.
Swaps the text and emoji glyphs for Lucide icons from react-icons, which is already a dependency. The mix of text labels, punctuation and a full-colour link emoji never read as one set. Layout is unchanged — same round icn-btn row, same selects, same textarea. The per-tool style overrides go away with the text they existed for: bold's font weight, italic's font style and the numbered list's smaller font size. The button carries inline-flex centering because .icn-btn sets line-height to 1px and is not a flex container, so an SVG child would otherwise sit off centre. Kept on the button rather than in app.scss so the shared .icn-btn class stays as it is for everything else using it.
Adds Write and Preview modes so the description can be read as it will be
rendered instead of only as raw Markdown.
markdownToHtml.js is a JS port of the block parser that already exists in PHP
on the Pro side: headings, emphasis, both list kinds, blockquotes, inline and
fenced code, links. Smart tags are shielded behind placeholders so the
underscore in ${first_name} is not read as emphasis, and link hrefs are limited
to http, https, mailto and tel.
dangerouslySetInnerHTML is safe here because the renderer never passes input
through. Every line is HTML escaped first and the tags are built by the parser,
so markup in the source comes out as text.
The toolbar row is reworked to fit the mode switch without wrapping. The two
150px selects were the widest elements in the row while being the least used,
and squeezing them truncated their labels to "Form Fie..." — worse than no
label at all. They collapse into one Insert dropdown holding both groups, which
is the merge tag pattern these editors usually use, and the reclaimed space
pays for hairline separators splitting the twelve buttons into headings,
emphasis, lists, and links and code.
The row renders in both modes with only the tools behind a write check. Hiding
the whole row in preview would take the mode switch with it and leave no way
back.
Note on fidelity: the preview renders standard Markdown semantics, so a heading
shows as a heading. Google Calendar flattens headings to bold and blockquotes
to paragraphs, so for that action the preview shows structure that only partly
survives. It is accurate for Trello, which renders Markdown natively.
"Rich Text Description" described an editor that no longer exists. The field takes Markdown with a preview, not rich text, and the name promised WYSIWYG behaviour the control does not have. Renames the string in all three places it appears: the utility checkbox, the Pro modal subject, and the Pro gate overlay on the editor. The supporting copy moves with it. The subtitle now names the Markdown and preview the neutral title leaves out, and the helper line points at the Preview tab, which nothing mentioned before. Both keep their second sentences about the Field Map and the Google Calendar conversion. The richTextDesc key is deliberately unchanged. It is persisted in saved flows and read by the Pro side hook, so renaming it would break existing integrations.
✅ WordPress Plugin Check Report
📊 ReportAll checks passed! No errors or warnings found. 🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check |
abdul-kaioum
approved these changes
Sep 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Release 2.10.4. Adds a shared "action user" source so WordPress actions can run for the logged-in user or for a user resolved from a mapped email field, adds a Markdown editor with live preview for Google Calendar event descriptions (Pro-gated), and ships a broad hardening pass across integrations, AJAX handling and the admin UI.
Motivation & Context
WordPress-side actions (LMS, membership, affiliate, gamification) previously enrolled/unenrolled whoever the request context happened to belong to — in LearnDash and LifterLMS the unenroll actions used a hardcoded user id, so they removed the wrong person. A single reusable user-source control fixes this consistently across twelve integrations.
Alongside that, a review pass found a set of real defects in the integrations (dropped fields, mislabelled successes, unpaginated fetchers, silent GET webhooks) and two crash paths in activation/checkout. Those are fixed here rather than spread over separate releases.
Related Links: (if applicable)
Type of Change
Key Changes
Action User Resolution
backend/Core/Util/ActionUser.phpplus theUserSourceSelect/UserEmailFieldMapfrontend helpers — one control to choose between the logged-in user and a user resolved from a mapped email field.Google Calendar & Markdown Editor
frontend/src/Utils/markdownToHtml.jsfor the preview rendering.Integrations
+-addressed emails.Core & Frontend
ErrorBoundaryso a render error contains itself instead of blanking the admin app.bitsFetchfor admin-ajax responses corrupted by stray PHP output.Config::withPrefix().Release
readme.txt.Checklist
Changelog