Skip to content

Users: Show the profile sections other plugins add in the native profile - #1021

Merged
epeicher merged 3 commits into
WordPress:trunkfrom
juanlentino:fix/profile-plugin-sections
Oct 30, 2026
Merged

epeicher merged 3 commits into
WordPress:trunkfrom
juanlentino:fix/profile-plugin-sections

Conversation

@juanlentino

@juanlentino juanlentino commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #1020

What this changes

The native User Edit window and the Users window's Profile tab (<os-user-profile>) now show what other plugins add to the profile screen, under a "More settings" heading below the native form. Saving it works the same as on the classic screen. This applies to any plugin that uses core's profile hooks:

  • show_user_profile (your own profile) and edit_user_profile (someone else's): the sections after Application Passwords;
  • personal_options: rows inside core's Personal Options table;
  • profile_personal_options: the block right after that table, on your own profile.

How

When the screen being shown has callbacks on any of those hooks, <os-user-profile> embeds the real profile.php or user-edit.php?user_id=N with wp.os.embedAdminPage(), carrying openstation_profile_sections=1. On that flag the server marks where each hook's output begins and narrows the page with CSS to that output. This follows the AGENTS.md rule that an admin screen inside a native window goes through embedAdminPage(), as Posts' Add Post tab does.

Nothing is removed from the page. Core's nonce, hidden fields and "Update Profile" button stay in the form, so:

  • the plugin's markup and the scripts it enqueues for profile.php / user-edit.php load as usual;
  • the plugin's own nonces and personal_options_update / edit_user_profile_update run through core's handler;
  • core's validation and user_profile_update_errors messages show as on the classic page.

The flag is posted back as a hidden field (the form's action has no query string), and a wp_redirect filter keeps it on the redirect after saving.

Things an embed does not do on its own, handled in apps/users/profile/plugin-sections.ts:

  • Height. The embed is sized to the page's #profile-page wrapper and follows it with a ResizeObserver. It is not sized from the document height, because the chromeless document is at least as tall as its frame and only grows.
  • Links. A link to the profile screen, or to wp-login.php (a plugin asking the user to re-authenticate before changing a setting), navigates in place: a small capture-phase listener on the narrowed page lets the browser follow it before the chromeless bridge hands it to the shell. Other admin links are routed the way trunk now routes any embed's admin links (fix(native-windows): switch to list tab on embedded editor back navigation #952). Off-site links (os-external-link) open in a new tab.
  • Round trips. If the embed lands on the whole profile screen without the flag (the return from wp-login.php re-authentication), it is sent back to the narrowed screen with the same anchor, once, and only when the page is the profile form. A page that is not (for example "Invalid user ID.") stays as it is. The embed URL carries openstation_chromeless=1 itself, so this does not depend on the Sec-Fetch fallback.
  • Empty hooks. A plugin can hook the profile and print nothing. The section stays hidden when the narrowed page has nothing visible besides the submit button.

After the native form saves, the embed reloads when the user has not edited anything in it. Otherwise it would post the core field values it loaded with and could undo the change just made. If the user has edited a field in it, it is not reloaded (that would drop the edit): the fields they left alone take the server's fresh values, fetched again, and the embed saves itself. Neither the plugin edit nor the core change is lost (apps/users/profile/plugin-sections-form.ts).

Files:

  • apps/users/parts/plugin-sections.php (new): the facts flag, request detection, markers, CSS, redirect filter.
  • apps/users/profile/plugin-sections.ts (new): the embed, sizing, off-site links, empty check, what happens after the native save.
  • apps/users/profile/plugin-sections-form.ts (new): the re-narrow guard, and the form snapshot and refresh for unsaved plugin edits.
  • apps/users/profile/index.ts, types.ts: mount and remount the section, call it after the native save.
  • apps/users/parts/facts.php, apps/users/users.os.php, apps/user-edit/user-edit.os.php, apps/users/users.os.ts, apps/user-edit/user-edit.os.ts: wiring.
  • docs/javascript-reference.md: the <os-user-profile> section.

Cost

When any plugin hooks the profile (common on real sites), opening the User Edit window or the Users Profile tab also loads the classic profile screen with its plugins' scripts. A follow-up could defer the embed until the section scrolls into view.

Alternatives considered

  • Fall back to the classic screen when the hooks have callbacks. This would turn the native window off on most real sites, since many plugins hook the profile.
  • Capture the hook output with ob_start() and render it in the native form. The plugins' scripts would not load, and their save hooks only run on core's form post. There is no precedent for this in the codebase.

Limitations

  • "Additional Capabilities", which core prints after the plugin hooks, shows when the user has extra capabilities, as on the classic page.
  • Markup a plugin's script inserts into core's own sections after load is hidden with those sections.

Testing

Local site: WordPress 7.1.3, PHP 8.3, a security plugin that adds a profile section with its own scripts and nonces and requires re-authentication through wp-login.php before its options can change, and an mu-plugin that adds a field on show_user_profile / edit_user_profile, a row on personal_options and a block on profile_personal_options, saved on personal_options_update / edit_user_profile_update. It ran in WordPress Playground CLI because Docker was not available for wp-env.

Manual, with the native Users window on:

  • Edit user window, own profile: the Personal Options table shows only the plugin row; the profile_personal_options block, the mu-plugin section and the security plugin's section show; core sections are hidden.
  • Users window, Profile tab: same.
  • Edit user window for another user (user-edit.php?user_id=2): sections show (edit_user_profile). Saving the field persists. Email and role are unchanged.
  • Changing a setting in the security plugin's section, the mu-plugin field and the personal_options row, then Update Profile: "Profile updated", values persist, and the embed stays narrowed after the redirect.
  • The security plugin's re-authentication link: its wp-login.php form opens in the embed. After completing it, the plugin redirects to profile.php#<anchor>, which comes back narrowed and editable.
  • Saving the native form with nothing edited in the embed reloads it (no post), and it then carries the new nickname.
  • Editing the mu-plugin field in the embed, then changing the nickname and clicking the native Save changes: both persist; the embed posts once and shows "Profile updated".
  • A user ID that does not exist: the embed loads "Invalid user ID." once (no reloads in 8 seconds) and the section stays hidden.
  • After the trunk merge: the re-authentication link opens its wp-login.php form inside the embed (no new window), and completing it returns to the narrowed profile.php#<anchor>.
  • Security plugin deactivated and only a show_user_profile callback that prints nothing: no "More settings" section.

Automated:

  • npm run build
  • npm run typecheck
  • npm run lint: 0 errors (existing warnings only, none in changed files)
  • npm run test:js: 544 files, 6932 tests pass. Four new cases in apps/users/profile/index.test.ts; the re-narrow guard and the refresh cases each fail when their logic is broken.
  • vendor/bin/phpcs -n over the repo (306 files): clean. npm run lint:php runs inside wp-env, which I could not start, so I ran the same ruleset directly.
  • npm run test:php: two new cases added to tests/phpunit/tests/userEditApp.php, not run locally (no Docker). They cover the facts flag for each hook, the marker, and the redirect flag.
  • npm run lint:unused: same output as trunk.

Engineered with Claude as a pair programmer.

🤖 Generated with Claude Code

Open WordPress Playground Preview

The User Edit window and the Users window's Profile tab edit the core
fields over REST, so nothing a plugin hooks onto the profile screens
appeared there (show_user_profile, edit_user_profile, personal_options,
profile_personal_options), and personal_options_update /
edit_user_profile_update never ran.

When the screen has callbacks on any of those hooks, <os-user-profile>
now embeds the real profile.php / user-edit.php below its form with
wp.os.embedAdminPage(), narrowed with CSS to what the hooks print. The
plugin's markup, scripts, nonces and save hook run as on the classic
page. The element routes the embed's links, follows a wp-login.php
re-authentication back to the narrowed screen, hides the section when
the hooks print nothing, and reloads the embed after its own form
saves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@juanlentino
juanlentino force-pushed the fix/profile-plugin-sections branch from 79b11e5 to db258cd Compare October 16, 2026 17:47
@epeicher

Copy link
Copy Markdown
Collaborator

Thanks so much @juanlentino, embedding the real profile.php so core's nonce, capability checks and save hooks all still apply is a really clean approach. I tested it with a small plugin field plus WooCommerce's own sections: they show up and save through Update Profile, and core fields stay untouched. 🎉

Two things I could reproduce. When the embedded page isn't a profile form (for example a user that doesn't exist, which returns "Invalid user ID."), onLoad re-narrows it with no guard and the section reloads forever. I saw 280+ requests in 12 seconds (plugin-sections.ts#L104). Re-narrowing only when #your-profile is present, and once, would stop it. Also, typing in a plugin field and then clicking the main Save changes says "Profile saved." but discards that edit, because the reload after the native save rebuilds the embed (index.ts#L189). Warning before discarding, or saving the embed too, would avoid surprising people.

juanlentino and others added 2 commits October 30, 2026 08:01
The embed re-narrowed any page on the profile screens that lacked the
marker, including pages that are not the profile form ("Invalid user
ID."), so it reloaded without end. It now re-narrows only the profile
form, and once per navigation.

A save from the native form reloaded the embed and dropped edits made in
it. Now the embed is reloaded only when nothing in it was edited.
Otherwise the fields left alone take the server's fresh values and the
embed saves itself, so neither the plugin edit nor the core change is
lost.

Trunk's embeds route admin links themselves since WordPress#952, so the element
no longer does (it would open a second window) and the openUrl property
is gone. Links to the profile screens and wp-login.php stay in the embed
through a capture-phase listener on the narrowed page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@juanlentino

Copy link
Copy Markdown
Contributor Author

Thanks for testing it, @epeicher. Both are fixed in the latest push.

  • Reload loop. The embed re-narrows only when the loaded page is the profile form (#your-profile present), and only once per navigation. "Invalid user ID." now loads once and the section stays hidden (shouldRenarrow() in plugin-sections-form.ts).
  • Plugin edits lost on Save changes. After the native save, the embed is reloaded only when nothing in it was edited. If something was, it is not reloaded: the fields left alone take the server's fresh values (the page is fetched again) and the embed submits itself. The plugin edit is saved and the core change from the native form is not reverted.

I also merged trunk. Since #952 embeds route admin links themselves, so the element no longer does, which would have opened a second window. Links to the profile screen and to wp-login.php now stay in place through a small capture-phase listener on the narrowed page. The openUrl property is gone.

@epeicher epeicher left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks, Juan, for your great contributions! I have reviewed this and LGTM!

@epeicher
epeicher merged commit 1439a89 into WordPress:trunk Oct 30, 2026
8 checks passed
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.

Native Users window: profile settings plugins add through core's profile hooks are missing

2 participants