[v3.3.0] Regex replace fails with 404 - empty source name in URL path
# [v3.3.0] Regex replace is broken (Replace All does nothing; single-row Replace hits malformed REST URL) + Preset selection doesn’t populate fields + REST route duplicate registrations (patch included)
Report date: **2026-01-13**
---
## Environment
* **Plugin Version:** 3.3.0
* **WordPress Version:** 6.9 (single site)
* **PHP Version:** 8.3.28
* **Memory Limit:** 256M
* **Max Execution Time:** 30s
* **Permalink Structure:** Custom (`/%category%/%postname%/`)
* **Browser:** Chrome 143.0.0.0 (Windows 11)
---
## Debug Information (from plugin error dialog)
```text
Plugin: 3.3.0
WordPress: 6.9 (single)
PHP: 8.3.28 256M 30s
Browser: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36
JavaScript: https://example.com/wp-content/plugins/search-regex/search-regex.js
REST API: https://example.com/wp-json/
Query: ?page=search-regex.php
Error: URL とリクエストメソッドに一致するルートが見つかりませんでした。 (rest_no_route)
Raw: {"code":"rest_no_route","message":"URL \u3068\u30ea\u30af\u30a8\u30b9\u30c8\u30e1\u30bd\u30c3\u30c9\u306b\u4e00\u81f4\u3059\u308b\u30eb\u30fc\u30c8\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3067\u3057\u305f\u3002","data":{"status":404}}
```
> **Note:** The REST API itself (`/wp-json/search-regex/v1`) is accessible and returns valid route information. This 404 occurs specifically when performing regex replace operations due to a malformed URL (`/source//row/` with empty source segment).
---
## Troubleshooting already performed
* Disabled all other plugins → issue persists
* Cleared browser cache → issue persists
* Verified REST API is accessible (`/wp-json/search-regex/v1` returns valid JSON)
* Tested in Chrome DevTools (Network tab) → confirmed malformed URL is the cause
---
## Bug 1 (CRITICAL): Regex Replace Completely Broken
### Severity
**CRITICAL** — Regex replacement is completely broken (both single row and “Replace All”).
### What works vs. what doesn’t
* **Normal (non-regex) text replace:** Works
* **Regex replace:** Does not work
There are **two different failure modes** depending on which action is used.
---
### 1) “Replace All” button (Regex enabled): nothing happens (no request is sent)
#### Symptom
When I click **“Replace All”** with **Regex** enabled:
* Nothing happens in the UI
* No network request is sent (DevTools → Network shows nothing)
* No error appears in the browser console (DevTools → Console)
* The button appears to do nothing
#### Reproduction steps (“Replace All”)
1. Install **Search Regex v3.3.0** on **WordPress 6.9**
2. Go to **Tools → Search Regex**
3. Enable the **Regex** flag
4. Perform a regex search (example):
```regex
p2='[^']*'\s+r='
```
5. Click the **“Replace All”** button
6. **Result:** Nothing happens, and there is **no** request in DevTools
---
### 2) Single-row “Replace” (Regex enabled): malformed URL → 404 `rest_no_route`
#### Symptom
When I click **“Replace”** on a specific result row with **Regex** enabled:
* A network request **is** sent
* The request URL is malformed because the `source` segment is empty
* WordPress REST API returns **404** with `rest_no_route`
#### Expected URL
```http
POST /wp-json/search-regex/v1/source/posts/row/1327/
```
#### Actual URL sent
```http
POST /wp-json/search-regex/v1/source//row/1327/
```
Note the double slash: `source//row` — the `source` path parameter is empty.
#### Request details (captured from DevTools)
```text
URL: https://example.com/wp-json/search-regex/v1/source//row/1327/
Method: POST
Body: {
"searchPhrase": "p2='[^']*'\\s+r='",
"searchFlags": ["case", "regex"],
"source": ["posts"], // <-- Source is correctly set in body, but NOT in URL
"replacement": {
"column": "post_content",
"operation": "replace",
"source": "", // <-- Empty here too
"replaceValue": "p2='100' r2='",
"searchValue": "p2='100' r='",
"posId": 27171
},
...
}
```
The `source` field is correctly set in the request body as `["posts"]`, but:
1. The URL path parameter is empty (`/source//row/`)
2. The `replacement.source` field is also empty
#### Error response
```json
{
"code": "rest_no_route",
"message": "URL とリクエストメソッドに一致するルートが見つかりませんでした。",
"data": {"status": 404}
}
```
> ("No route was found matching the URL and request method.")
#### Reproduction steps (single-row Replace)
1. Install **Search Regex v3.3.0** on **WordPress 6.9**
2. Go to **Tools → Search Regex**
3. Enable the **Regex** flag
4. Perform a regex search (example):
```regex
p2='[^']*'\s+r='
```
5. Find a match and click **Replace** on a specific row
6. **Result:** 404 error occurs
7. Check DevTools → Network to confirm the malformed URL:
```text
/wp-json/search-regex/v1/source//row/{rowId}/
```
---
### Comparison table
| Operation | Regex Mode | Normal Mode |
| ----------------------------- | -------------------------------- | ----------- |
| **Replace All** button | **Nothing happens** (no request) | Works |
| **Single row Replace** button | 404 error (malformed URL) | Works |
---
### Root cause (likely)
This appears to be a frontend JavaScript bug in the built bundle:
* `build/search-regex.js`
The repository does not include the original frontend source (only the built bundle), so I can’t point to the exact original source file/line without access to the unbundled source.
**Possible causes:**
1. **Replace All:** click handler validation fails silently in regex mode, or the handler is not bound correctly
2. **Single-row Replace:** the `source` parameter is not passed correctly when building the row URL in regex mode
---
### Attempted workaround (backend) and why it does not fix Bug 1
I attempted a backend workaround using a `rest_pre_dispatch` filter to intercept malformed URLs and extract `source` from the request body. However, **this does NOT fix Bug 1** because:
* **Replace All:** never sends a request → nothing to intercept
* **Single-row Replace:** the workaround should catch the malformed URL, but in practice it only helped in preset-related situations (see Bug 3/4 cleanup area). It does not fix direct regex searches end-to-end because the critical problem is still in the frontend behavior.
**Status:** **NOT FIXED** — requires a frontend fix.
---
## Bug 2 (CRITICAL): Preset selection does not populate the input fields (works only after refresh)
### Severity
**CRITICAL** — Presets cannot be used properly without refreshing the page.
### Symptom
In **Advanced Mode**, selecting a preset from the preset dropdown does not populate the UI fields (search phrase, source, filters, etc.). The form remains empty.
However, if I refresh the page (F5) while the preset is present in the URL (for example `?preset=6965b2bc28f5b`), then the preset loads correctly and the fields populate.
### Comparison
| Action | Result |
| -------------------------------------- | ------------------------------------ |
| Select preset from UI dropdown | Input fields remain **EMPTY** |
| Refresh (F5) with `?preset=xxx` in URL | Input fields **POPULATED** correctly |
### Reproduction steps
1. Install **Search Regex v3.3.0**
2. Go to **Tools → Search Regex**
3. Switch to **Advanced Mode**
4. Select a preset from the preset dropdown
5. **Result:** Input fields remain empty
6. Copy the URL (it now includes `?preset=xxx`)
7. Press **F5** to refresh
8. **Result:** Input fields populate with preset data
### Backend investigation (preload looks correct)
From the backend side, presets are correctly preloaded and passed to JS. Example:
```php
// In Admin::get_preload_data()
return [
'sources' => Source\Manager::get_all_grouped(),
'presets' => $presets, // <-- All presets are available
'schema' => $schema,
'labels' => $this->get_preload_labels( $presets ),
];
// Passed to frontend as:
// SearchRegexi10n.preload.presets
```
So the preload mechanism works on initial page load (reading the URL parameter), but the dynamic preset selection handler does not update the form.
**Status:** **NOT FIXED** — requires frontend JavaScript fix (cannot be fixed cleanly from backend alone).
---
## Bug 3 (LOW): `/preset` REST route is registered twice (GET + POST)
### Severity
**LOW** — may cause intermittent issues; also unnecessary/fragile.
### File
`includes/api/route/class-preset-route.php` (Lines 70–88)
### Current code (duplicate registrations)
```php
public function __construct( $namespace ) {
register_rest_route(
$namespace,
'/preset',
[
$this->get_route( \WP_REST_Server::READABLE, 'route_list', [ $this, 'permission_callback' ] ),
]
);
register_rest_route(
$namespace,
'/preset',
[
array_merge(
[ 'args' => $this->get_preset_params() ],
$this->get_route( \WP_REST_Server::EDITABLE, 'route_create', [ $this, 'permission_callback' ] )
),
]
);
// ...
}
```
### Fix (combine into a single `register_rest_route()` call)
```php
public function __construct( $namespace ) {
register_rest_route(
$namespace,
'/preset',
[
$this->get_route( \WP_REST_Server::READABLE, 'route_list', [ $this, 'permission_callback' ] ),
array_merge(
[ 'args' => $this->get_preset_params() ],
$this->get_route( \WP_REST_Server::EDITABLE, 'route_create', [ $this, 'permission_callback' ] )
),
]
);
// ...
}
```
**Status:** **FIXED** (patch provided below)
---
## Bug 4 (LOW): `/source/{source}/row/{rowId}` REST route is registered twice (GET + POST)
### Severity
**LOW** — may cause intermittent issues; also unnecessary/fragile.
### File
`includes/api/route/class-source-route.php` (Lines 67–98)
### Current code (duplicate registrations)
```php
register_rest_route(
$route_namespace,
'/source/(?P<source>[a-z\-\_]+)/row/(?P<rowId>[\d]+)',
array_merge(
[
'args' => $this->get_source_params(),
],
$this->get_route( \WP_REST_Server::READABLE, 'loadRow', [ $this, 'permission_callback' ] ),
)
);
register_rest_route(
$route_namespace,
'/source/(?P<source>[a-z\-\_]+)/row/(?P<rowId>[\d]+)',
array_merge(
[
'args' => array_merge(
[
'replacement' => [
'description' => 'Row replacement. A single action.',
'type' => 'object',
'validate_callback' => [ $this, 'validate_replacement' ],
'required' => true,
],
],
$this->get_source_params(),
$this->get_search_params()
),
],
$this->get_route( \WP_REST_Server::EDITABLE, 'saveRow', [ $this, 'permission_callback' ] ),
)
);
```
### Fix (combine into a single registration)
```php
register_rest_route(
$route_namespace,
'/source/(?P<source>[a-z\-\_]+)/row/(?P<rowId>[\d]+)',
[
array_merge(
[
'args' => $this->get_source_params(),
],
$this->get_route( \WP_REST_Server::READABLE, 'loadRow', [ $this, 'permission_callback' ] ),
),
array_merge(
[
'args' => array_merge(
[
'replacement' => [
'description' => 'Row replacement. A single action.',
'type' => 'object',
'validate_callback' => [ $this, 'validate_replacement' ],
'required' => true,
],
],
$this->get_source_params(),
$this->get_search_params()
),
],
$this->get_route( \WP_REST_Server::EDITABLE, 'saveRow', [ $this, 'permission_callback' ] ),
),
]
);
```
**Status:** **FIXED** (patch provided below)
---
## Summary
| Priority | Bug | Description | Status |
| ------------ | ----- | ---------------------------------------------------------------------------------------- | ------------------- |
| **CRITICAL** | Bug 1 | Regex replace broken (“Replace All” = no request; single-row = 404 due to malformed URL) | **NOT FIXED** |
| **CRITICAL** | Bug 2 | Selecting a preset does not populate input fields until refresh | **NOT FIXED** |
| LOW | Bug 3 | Duplicate route registration for `/preset` | FIXED (patch below) |
| LOW | Bug 4 | Duplicate route registration for `/source/.../row/...` | FIXED (patch below) |
---
## Additional notes
* Normal (non-regex) text replacement works correctly
* REST API routes are registered correctly (confirmed by accessing):
```text
/wp-json/search-regex/v1
```
* Bug 1 and Bug 2 are frontend JavaScript issues that require frontend source access to fix properly
* Bug 1 affects all regex replace operations (both direct and via preset)
* Bug 2 affects preset selection UI behavior
* A backend workaround (`rest_pre_dispatch`) was attempted, but does not fix the critical frontend bugs
---
## Complete Patch (full text, no omissions)
### Modified files
1. **`includes/api/class-api.php`** — add request interceptor filter (attempted workaround; does not fix critical bugs)
2. **`includes/api/route/class-source-route.php`** — fix duplicate registration (Bug 4) + add fallback route + add `saveRowFallback()` (workaround attempt)
3. **`includes/api/route/class-preset-route.php`** — fix duplicate registration (Bug 3)
**Note:** The patches for Bug 3 and Bug 4 (duplicate route registration) are confirmed working. The `rest_pre_dispatch` workaround was an attempt to mitigate Bug 1, but it does not fix the critical frontend behaviors.
---
### 1) `includes/api/class-api.php`
Add a `rest_pre_dispatch` filter that intercepts malformed URLs (with empty source) and handles them correctly by extracting the source from the request body.
**Full modified file:**
```php
<?php
namespace SearchRegex\Api;
class Api {
const SEARCHREGEX_API_NAMESPACE = 'search-regex/v1';
/**
* Instance variable
*/
private static ?Api $instance = null;
/**
* Array of endpoint routes
*
* @var Route[]
* @phpstan-ignore property.onlyWritten
*/
private array $routes = [];
/**
* Create API
*
* @return Api
*/
public static function init() {
if ( is_null( self::$instance ) ) {
self::$instance = new Api();
}
return self::$instance;
}
public function __construct() {
global $wpdb;
$wpdb->hide_errors();
// Fix malformed URLs from frontend bug (empty source in URL)
add_filter( 'rest_pre_dispatch', [ $this, 'fix_empty_source_in_url' ], 10, 3 );
$this->routes[] = new Route\Search_Route( self::SEARCHREGEX_API_NAMESPACE );
$this->routes[] = new Route\Source_Route( self::SEARCHREGEX_API_NAMESPACE );
$this->routes[] = new Route\Plugin_Route( self::SEARCHREGEX_API_NAMESPACE );
$this->routes[] = new Route\Settings_Route( self::SEARCHREGEX_API_NAMESPACE );
$this->routes[] = new Route\Preset_Route( self::SEARCHREGEX_API_NAMESPACE );
}
/**
* Fix malformed requests where source is empty in URL but present in body.
* This is a workaround for a frontend bug in regex replace.
*
* Intercepts URLs like /source//row/1327 and handles them by extracting
* the source from the request body.
*
* @param mixed $result Response to replace the requested version with.
* @param \WP_REST_Server $server Server instance.
* @param \WP_REST_Request<array<string, mixed>> $request Request.
* @return mixed
*/
public function fix_empty_source_in_url( $result, $server, $request ) {
// Don't override if already handled
if ( $result !== null ) {
return $result;
}
$route = $request->get_route();
// Only process search-regex source routes with empty source
// Match pattern like /search-regex/v1/source//row/1327
if ( ! preg_match( '#^/search-regex/v1/source//row/(\d+)/?$#', $route, $matches ) ) {
return $result;
}
// Only handle POST requests (saveRow)
if ( $request->get_method() !== 'POST' ) {
return $result;
}
$row_id = $matches[1];
$params = $request->get_params();
// Get source from request body
$source = $params['source'] ?? null;
if ( empty( $source ) ) {
return new \WP_Error( 'rest_invalid_param', 'Source is required in request body', [ 'status' => 400 ] );
}
// Ensure source is a string (take first if array)
if ( is_array( $source ) ) {
$source = $source[0] ?? null;
}
if ( empty( $source ) || ! preg_match( '/^[a-z\-\_]+$/', $source ) ) {
return new \WP_Error( 'rest_invalid_param', 'Invalid source format', [ 'status' => 400 ] );
}
// Set the correct parameters
$request->set_param( 'source', [ $source ] );
$request->set_param( 'rowId', $row_id );
// Find and call the correct handler (saveRow)
foreach ( $this->routes as $r ) {
if ( $r instanceof Route\Source_Route ) {
return $r->saveRow( $request );
}
}
return $result;
}
}
```
---
### 2) `includes/api/route/class-source-route.php`
**Changes:**
1. Combine duplicate route registrations for `/source/{source}/row/{rowId}` into a single call (Bug 4 fix)
2. Add fallback route `/source/row/{rowId}` for when the frontend sends a request with missing source in the URL (workaround attempt)
3. Add `saveRowFallback()` method
**In `__construct()`, replace the two separate registrations with:**
```php
register_rest_route(
$route_namespace,
'/source/(?P<source>[a-z\-\_]+)/row/(?P<rowId>[\d]+)',
[
array_merge(
[
'args' => $this->get_source_params(),
],
$this->get_route( \WP_REST_Server::READABLE, 'loadRow', [ $this, 'permission_callback' ] ),
),
array_merge(
[
'args' => array_merge(
[
'replacement' => [
'description' => 'Row replacement. A single action.',
'type' => 'object',
'validate_callback' => [ $this, 'validate_replacement' ],
'required' => true,
],
],
$this->get_source_params(),
$this->get_search_params()
),
],
$this->get_route( \WP_REST_Server::EDITABLE, 'saveRow', [ $this, 'permission_callback' ] ),
),
]
);
// Fallback route: when frontend sends empty source in URL (regex replace bug workaround)
// This route handles /source//row/{rowId} by extracting source from request body
register_rest_route(
$route_namespace,
'/source/row/(?P<rowId>[\d]+)',
[
array_merge(
[
'args' => array_merge(
[
'replacement' => [
'description' => 'Row replacement. A single action.',
'type' => 'object',
'validate_callback' => [ $this, 'validate_replacement' ],
'required' => true,
],
],
$this->get_source_params(),
$this->get_search_params()
),
],
$this->get_route( \WP_REST_Server::EDITABLE, 'saveRowFallback', [ $this, 'permission_callback' ] ),
),
]
);
```
**Add this new method to the class:**
```php
/**
* Fallback: Perform a replacement on a row when source is missing from URL.
* Extracts source from request body instead.
*
* @param WP_REST_Request<array<string, mixed>> $request The request.
* @return WP_Error|array<string, mixed> Return an array of results, or a WP_Error
*/
public function saveRowFallback( WP_REST_Request $request ) {
$params = $request->get_params();
// Source is missing from URL, try to get it from request body
if ( ! isset( $params['source'] ) || empty( $params['source'] ) ) {
return new WP_Error( 'rest_invalid_param', 'Source is required', [ 'status' => 400 ] );
}
// Ensure source is an array
if ( ! is_array( $params['source'] ) ) {
$params['source'] = [ $params['source'] ];
}
// Delegate to the main saveRow logic
return $this->saveRow( $request );
}
```
---
### 3) `includes/api/route/class-preset-route.php`
**In `__construct()`, replace the two separate registrations for `/preset` with:**
```php
register_rest_route(
$namespace,
'/preset',
[
$this->get_route( \WP_REST_Server::READABLE, 'route_list', [ $this, 'permission_callback' ] ),
array_merge(
[ 'args' => $this->get_preset_params() ],
$this->get_route( \WP_REST_Server::EDITABLE, 'route_create', [ $this, 'permission_callback' ] )
),
]
);
```
---
1 条评论