Skip to content

Search Blocks: enable @wordpress/i18n in the IAPI view bundle (SEARCH-168)#48551

Draft
kangzj wants to merge 3 commits intotrunkfrom
echo/search-168-localize-rating-aria-label
Draft

Search Blocks: enable @wordpress/i18n in the IAPI view bundle (SEARCH-168)#48551
kangzj wants to merge 3 commits intotrunkfrom
echo/search-168-localize-rating-aria-label

Conversation

@kangzj
Copy link
Copy Markdown
Contributor

@kangzj kangzj commented May 6, 2026

Fixes SEARCH-168

Why

A shopper using a screen reader on a non-English Jetpack Search store currently hears the product rating in English ("4.5 out of 5 stars based on 42 reviews") even though everything else on the page is translated. The fix gives the Interactivity API view bundle a real @wordpress/i18n it can call, so the rating aria-label — and every other JS-emitted view-bundle string — picks up the page locale.

This also unblocks any future Jetpack Search block code that wants to call __() / _n() / sprintf() directly instead of detouring through state.strings.* seeded from PHP.

Proposed changes

  • Webpack — externalize @wordpress/i18n as a script-module reference. tools/webpack.blocks.config.js passes a requestToExternalModule callback that returns 'module @wordpress/i18n' so the IAPI bundle emits a hoisted static import * from "@wordpress/i18n" (same pattern DEP already uses for @wordpress/interactivity). Without this, DEP throws "Attempted to use WordPress script in a module" — core only registers @wordpress/interactivity (and a11y / router) as script modules.
  • Tiny ESM shim at src/search-blocks/store/i18n-shim.js. Re-exports the live functions on window.wp.i18n (__, _x, _n, _nx, sprintf, isRTL, hasTranslation, setLocaleData) with identity fallbacks if wp-i18n somehow didn't load. Built as its own webpack entry → build/search-blocks/store/i18n-shim.js.
  • PHP — register the shim under the canonical @wordpress/i18n module ID in Search_Blocks::register_i18n_module() via wp_register_script_module(). The browser's import map now resolves @wordpress/i18n to the shim, so calls go through the same window.wp.i18n instance the classic wp-i18n script provides.
  • Translations land via the existing wp-jp-i18n-loader pipeline. Search_Blocks::enqueue_i18n_runtime() enqueues wp-i18n and wp-jp-i18n-loader (registered by Automattic\Jetpack\Assets, with state.baseUrl / state.locale / state.domainPaths['jetpack-search-pkg'] already populated). src/search-blocks/store/i18n-bootstrap.js calls wp.jpI18nLoader.downloadI18n( bundlePath, 'jetpack-search-pkg', 'plugin' ) from each entry that has its own translatable strings. jp-i18n-loader hashes the bundle path against state.domainPaths to match Jetpack's per-handle .json filenames, then setLocaleData()s the result into the shared wp.i18n runtime — same path @automattic/i18n-loader-webpack-plugin injects into classic-script bundles.
  • Use the new path in three view-bundle call sites:
    • result-utils.js buildRatingAriaLabel — the original SEARCH-168 offender. Now calls __( '%s out of 5 stars', ... ) for the no-reviews branch and _n( '%1$s out of 5 stars based on %2$d review', '%1$s out of 5 stars based on %2$d reviews', reviewCount, ... ) + sprintf for the with-reviews branch (matches the source strings the editor's edit.js already extracts).
    • store/index.js computeResultsCountText — drops the state.strings?.searching ?? 'Searching…' indirection in favour of native __/_n/sprintf.
    • active-filters/view.js activePills — same swap on the "Remove %s" pill aria-label.
  • Drop the now-unused build_initial_strings() PHP seed and its test assertions. Strings live in JS source now.

Design choices (why this shape, not the alternatives)

A few decisions deserve a paper trail because the obvious alternatives looked simpler at first and turned out not to be:

Why I18nLoaderPlugin: false. The standard repo path for "make @wordpress/i18n work in a webpack bundle" is @automattic/i18n-loader-webpack-plugin, which auto-injects a runtime that calls wp.jpI18nLoader.downloadI18n() whenever a chunk loads. But re-reading I18nLoaderPlugin.js:196loop: for ( const subchunk of chunk.getAllAsyncChunks() ) — that auto-injection only fires on async sub-chunks. Our IAPI bundles are 13 self-contained entry chunks with zero async children, so the plugin would skip every one of them and emit no runtime code. Even if we worked around the secondary issue (DEP throwing on @wordpress/jp-i18n-loader in module mode), the plugin would do nothing useful for our main chunks. We'd still need a manual downloadI18n() call. So we leave the plugin disabled and call jp-i18n-loader's exported runtime directly from i18n-bootstrap.js — same library it would have called for us, just invoked from the right place for our chunk shape.

Why externalize via 'module @wordpress/i18n' + a registered shim, not 'var wp.i18n'. Mapping @wordpress/i18n to a var wp.i18n global read is what DEP does in classic-script mode by default, and at first glance it would let us drop the shim file and the register_i18n_module() PHP. But in module-output mode DEP records the externalized value (not the original request) in its externalizedDeps set — so wp.i18n ends up in the .asset.php dependencies array. WP's script-module registry then silently refuses to enqueue any view bundle whose declared deps include an unresolvable script-module ID, breaking every block that imports @wordpress/i18n (verified empirically — search-results.js disappears from the page; flipping back to 'module @wordpress/i18n' restores it). We could post-process .asset.php files to strip wp.i18n, but at that point the shim is the cleaner answer: one ESM file + one wp_register_script_module() call, no asset-rewriting hack.

Why not seed strings from PHP via wp_interactivity_state() / state.strings.* (the path RSM-267 established). It works, but it forks every translatable JS string into two source-of-truth sites — a _n() call in PHP that produces the seeded value plus a JS reader that consumes it — and the seeding has to be re-done for every new string in every new IAPI block. Routing through @wordpress/i18n collapses both halves: the JS source carries the translatable string, the .pot extractor sees it, jp-i18n-loader fetches the translation. This PR also migrates the existing state.strings.* consumers (computeResultsCountText, activePills.ariaLabel) so the package converges on a single i18n path.

Why not bundle @wordpress/i18n inline in each view bundle. Webpack will happily inline the package's source if requestToExternalModule returns false, giving each bundle its own self-contained Tannin runtime. But the bundled instance is separate from window.wp.i18nwp.jpI18nLoader.downloadI18n() writes to the classic script's instance, so translations would never reach the bundled copies. We'd have to also bundle a private jp-i18n-loader and copy state across (~+15 KB per view bundle, x13 bundles) just to get back the integration the shim gives us in 30 lines.

Related product discussion/links

Does this pull request change what data or activity we track or use?

No.

Testing instructions

  1. Pull the branch and run the four-layer build matrix:
    pnpm jetpack build packages/my-jetpack
    pnpm jetpack build plugins/jetpack
    pnpm jetpack build packages/search
    pnpm jetpack build plugins/search
    
  2. Unit tests:
    • cd projects/packages/search && pnpm run test-scripts — 429/429 JS tests pass.
    • cd projects/packages/search && composer phpunit -- --filter=Search_Blocks_Test — 18/18 PHP tests pass.
  3. In the browser (any dev env where Jetpack Search blocks are enabled — e.g. with add_filter( 'jetpack_search_blocks_enabled', '__return_true' )):
    • Visit /?s=... (or any page that renders a Jetpack Search 3.0 block).
    • In DevTools, open the page source and confirm the import map maps @wordpress/i18n to the shim:
      "@wordpress/i18n":"…/jetpack-search/.../build/search-blocks/store/i18n-shim.js?ver=…"
      
    • Confirm the page's <script> tags include wp-i18n and wp-jp-i18n-loader as classic scripts.
    • In the console:
      wp.i18n.sprintf( wp.i18n._n( 'Found %d result', 'Found %d results', 5, 'jetpack-search-pkg' ), 5 )
      // → "Found 5 results" on en_US (source string round-trips)
      // → translated equivalent on a non-en_US locale with a .mo file
    • Run a search that returns hits — the count text below the input should read "Found N results" and update as you type.
    • On a non-en_US locale, watch the Network tab: wp.jpI18nLoader.downloadI18n() should fetch <lang>/plugins/<plugin-slug>-jetpack-search-pkg-<locale>-<hash>.json for each search-blocks bundle and inline setLocaleData(...) into wp.i18n once it lands. (en_US short-circuits in the loader — no .json fetch is expected.)
    • If WC products are indexed, switch a search-results block to the product layout and inspect a card's rating row: the aria-label should now be the translated "X out of 5 stars based on N reviews" string (front end matches editor preview).

@kangzj kangzj added [Status] Needs Review This PR is ready for review. [Package] Search Contains core Search functionality for Jetpack and Search plugins labels May 6, 2026
@kangzj kangzj self-assigned this May 6, 2026
@kangzj

This comment has been minimized.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 6, 2026

Are you an Automattician? Please test your changes on all WordPress.com environments to help mitigate accidental explosions.

  • To test on WoA, go to the Plugins menu on a WoA dev site. Click on the "Upload" button and follow the upgrade flow to be able to upload, install, and activate the Jetpack Beta plugin. Once the plugin is active, go to Jetpack > Jetpack Beta, select your plugin (Jetpack), and enable the echo/search-168-localize-rating-aria-label branch.
  • To test on Simple, run the following command on your sandbox:
bin/jetpack-downloader test jetpack echo/search-168-localize-rating-aria-label

Interested in more tips and information?

  • In your local development environment, use the jetpack rsync command to sync your changes to a WoA dev blog.
  • Read more about our development workflow here: PCYsg-eg0-p2
  • Figure out when your changes will be shipped to customers here: PCYsg-eg5-p2

@claude

This comment has been minimized.

@kangzj

This comment has been minimized.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 6, 2026

Thank you for your PR!

When contributing to Jetpack, we have a few suggestions that can help us test and review your patch:

  • ✅ Include a description of your PR changes.
  • ✅ Add a "[Status]" label (In Progress, Needs Review, ...).
  • ✅ Add testing instructions.
  • ✅ Specify whether this PR includes any changes to data or privacy.
  • ✅ Add changelog entries to affected projects

This comment will be updated as you work on your PR and make changes. If you think that some of those checks are not needed for your PR, please explain why you think so. Thanks for cooperation 🤖


Follow this PR Review Process:

  1. Ensure all required checks appearing at the bottom of this PR are passing.
  2. Make sure to test your changes on all platforms that it applies to. You're responsible for the quality of the code you ship.
  3. You can use GitHub's Reviewers functionality to request a review.
  4. When it's reviewed and merged, you will be pinged in Slack to deploy the changes to WordPress.com simple once the build is done.

If you have questions about anything, reach out in #jetpack-developers for guidance!

This comment has been minimized.

Copilot finished work on behalf of kangzj May 6, 2026 03:48
@jp-launch-control

This comment has been minimized.

@kangzj kangzj force-pushed the echo/search-168-localize-rating-aria-label branch from 3cf1b71 to 74f8b90 Compare May 6, 2026 03:56
@kangzj

This comment has been minimized.

@kangzj

This comment has been minimized.

@kangzj kangzj added [Status] In Progress and removed [Status] Needs Review This PR is ready for review. labels May 6, 2026
@kangzj

This comment has been minimized.

@kangzj

This comment has been minimized.

@claude

This comment has been minimized.

This comment has been minimized.

Copilot finished work on behalf of kangzj May 6, 2026 04:00
@kangzj kangzj force-pushed the echo/search-168-localize-rating-aria-label branch from 74f8b90 to ef8151e Compare May 6, 2026 04:10
@kangzj
Copy link
Copy Markdown
Contributor Author

kangzj commented May 6, 2026

Review-cycle summary — 3cf1b71ef8151e

2 round(s); CI green; both AI reviewers approved; 0 inline threads to resolve.

What changed during the cycle

Commits added:

  • a74c2a5 — Search Blocks: enable @wordpress/i18n in the IAPI view bundle (SEARCH-168)
  • ef8151e — Address review: docblock, test coverage, explicit DEP fall-through, changelog Type

Diff summary: 10 files changed, 335 insertions(+), 90 deletions(-)

Review threads addressed:

Source Comment Resolution
claude[bot] (#IC_kwDOAOho7M8AAAABBVwvcA) inverted "first-wins" docblock; missing collect_locale_data() test coverage; explicit DEP fall-through; Plural-Forms casing; _nx fallback signature Docblock rewritten to match actual semantics (ef8151e); build_locale_data_payload() extracted with two new PHPUnit cases; requestToExternalModule now leads with an explicit early-return; casing handled via ?? fallback to the Western 2-form rule; shim fallback signature scoped to the unreachable wp-i18n-missing case.
copilot-swe-agent (#IC_kwDOAOho7M8AAAABBVyIQQ) same three actionable items + changelog Type: changedType: fixed Same commit ef8151e; changelog Type updated.
claude[bot] (re-review) (#IC_kwDOAOho7M8AAAABBV05Wg) "Ready to merge." No action — confirmation.
copilot-swe-agent (re-review) (#IC_kwDOAOho7M8AAAABBV1YTA) "Looks good to merge." No action — confirmation.
jp-launch-control (#IC_kwDOAOho7M8AAAABBVz1Cw) Code-coverage delta (-6.80% in class-search-blocks.php; new i18n-shim.js at 0/16) Coverage check itself is pass. The drop is enqueue_i18n_runtime() / register_i18n_module() / collect_locale_data() wrapping WP registry functions (not unit-testable without a WP bootstrap), and i18n-shim.js is a browser-only ESM shim whose runtime path is exercised through result-utils.test.js / store.test.js against the real @wordpress/i18n npm package. Both reviewers explicitly accepted the gap as "no action needed."

Unaddressed (flagged for owner): None.

CI: all required checks passing (Test plugin upgrades is pending, deploy-preview only — not blocking).

@kangzj kangzj added [Status] Needs Team Review Obsolete. Use Needs Review instead. and removed [Status] In Progress labels May 6, 2026
@kangzj kangzj marked this pull request as draft May 6, 2026 04:40
…-168)

Externalize @wordpress/i18n to a script-module reference (via DEP's
requestToExternalModule) and register a tiny ESM shim that re-exports
window.wp.i18n as the @wordpress/i18n module. The classic wp-i18n script
is enqueued on every search-blocks page so window.wp.i18n is populated
synchronously before any deferred module evaluates; translations are
inlined as setLocaleData() against PHP's already-loaded gettext entries
for jetpack-search-pkg, so __() / _n() / sprintf() return localized
strings on first paint without depending on per-handle .json files.

Use the new path in:
- result-utils.js buildRatingAriaLabel — fixes the SEARCH-168 frontend
  product-rating aria label (was hardcoded English when used outside the
  editor preview).
- store/index.js computeResultsCountText — replaces the PHP-seeded
  state.strings reads with native __()/_n()/sprintf().
- active-filters/view.js activePills — same swap.

Drop the now-unused build_initial_strings() PHP seed and the test
assertions that pinned its shape.
kangzj added 2 commits May 7, 2026 10:29
…hangelog Type

- Fix the inverted "first-wins" claim in register_i18n_module()'s
  docblock: ours wins because we register first; flag the maintenance
  hazard if WP core later ships a native @wordpress/i18n module
  (raised by both Copilot and claude[bot]).
- Extract build_locale_data_payload() out of collect_locale_data() and
  cover it with two unit tests (Jed shape from a fake Translations,
  default Plural-Forms fallback when the header is missing). Closes
  the gap both reviewers flagged for the new PHP code.
- Make webpack.blocks.config.js's requestToExternalModule explicit:
  early-return for non-i18n requests so the DEP fall-through is
  visible at a glance (claude[bot] nit).
- Update changelog Type from "changed" to "fixed" to match the
  SEARCH-168 framing (Copilot nit).
… setLocaleData

The first cut of SEARCH-168 inlined a custom `setLocaleData()` payload from
PHP — duplicating what `Automattic\Jetpack\Assets`' `wp-jp-i18n-loader`
script + `@wordpress/jp-i18n-loader`'s `downloadI18n()` already do for
classic-script bundles. Switch to the established pipeline:

- `enqueue_i18n_runtime()` now just enqueues `wp-i18n` and `wp-jp-i18n-loader`
  (registered by `Automattic\Jetpack\Assets::wp_default_scripts_hook` with
  the locale + domainPath state already populated). Drop the bespoke
  `collect_locale_data()` / `build_locale_data_payload()` PHP helpers and
  their PHPUnit cases.
- `store/i18n-bootstrap.js` calls `wp.jpI18nLoader.downloadI18n( bundlePath,
  'jetpack-search-pkg', 'plugin' )` from each entry that has its own
  translatable strings (`store/index.js` and `active-filters/view.js`).
  jp-i18n-loader hashes the bundle path against `state.domainPaths` to
  match Jetpack's per-handle .json filenames, then `setLocaleData()`s the
  result into the live `wp.i18n` runtime — same path
  `@automattic/i18n-loader-webpack-plugin` injects into classic-script
  builds.
- The shim, `register_i18n_module()`, and the `'module @wordpress/i18n'`
  external stay: webpack still rewrites `import '@wordpress/i18n'` to a
  static script-module reference under the canonical `@wordpress/i18n`
  ID, and the shim re-exports `window.wp.i18n` by reference so calls go
  through the same instance jp-i18n-loader writes translations into.

`var wp.i18n` externalization was rejected during this refactor — DEP
records the externalized value (not the original request) in module
mode, which leaks `wp.i18n` into the .asset.php `dependencies` array;
WP's script-module registry then silently refuses to enqueue any view
bundle whose declared deps reference an unresolvable module ID,
breaking every block that uses `__()`.
@kangzj kangzj force-pushed the echo/search-168-localize-rating-aria-label branch from ef8151e to 248bef1 Compare May 6, 2026 22:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Package] Search Contains core Search functionality for Jetpack and Search plugins [Status] In Progress [Status] Needs Team Review Obsolete. Use Needs Review instead. [Tests] Includes Tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants