Skip to content

Fix /wp-admin blank page when response crosses Comlink worker boundary#3301

Merged
JanJakes merged 2 commits intoWordPress:trunkfrom
epeicher:fix/wp-admin-blank-page
Feb 25, 2026
Merged

Fix /wp-admin blank page when response crosses Comlink worker boundary#3301
JanJakes merged 2 commits intoWordPress:trunkfrom
epeicher:fix/wp-admin-blank-page

Conversation

@epeicher
Copy link
Copy Markdown
Contributor

@epeicher epeicher commented Feb 24, 2026

Motivation for the change, related issues

In Playground 3.1.2, navigating to /wp-admin (without trailing slash) in an incognito window shows a blank page instead of redirecting to the login page. This regression was introduced by the streaming response refactor (PR #3222).

Implementation details

StreamedPHPResponse.fromPHPResponse() created an empty headersStream and relied on setting the parsedHeaders property directly. This worked on the same thread, but when the response crossed a Comlink worker boundary, the transfer handler only serialized the raw headersStream — the parsedHeaders property was lost.

On the receiving thread, the deserialized response had an empty headersStream. When headers were accessed, parseHeadersStream read the empty stream, JSON.parse('') failed, and the fallback returned { headers: {}, httpStatusCode: 200 }. The 301 redirect became a 200 OK with no headers and an empty body.

The fix encodes headers into the headersStream using the JSON format that parseHeadersStream expects ({ status, headers: ["Name: value", ...] }), so headers survive Comlink serialization. The parsedHeaders fast-path is kept for same-thread access.

This affected all responses created via fromPHPResponse() or forHttpCode() that cross a worker boundary: directory trailing-slash 301 redirects, 404 responses, 502 responses, and static file responses.

Testing Instructions (or ideally a Blueprint)

  1. Run existing tests: npx nx test php-wasm-universal --testFile=php-response.spec.ts
  2. Run request handler tests: npx nx test php-wasm-universal --testFile=php-request-handler.spec.ts

Manual test

  1. Apply this branch and run the updated package by running: npx nx build php-wasm-universal
  2. Start the CLI by running: npx nx dev playground-cli server --wp=6.8 --php=8.4 --login
  3. On a browser incognito window, navigate to http://127.0.0.1:9400/wp-admin (please ensure that there isn't a trailing slash / at the end)
  4. Check that you can navigate to wp-admin successfully
  5. You can repeat steps 2-3 on trunk and check that you see a blank page in trunk

StreamedPHPResponse.fromPHPResponse() created an empty headersStream
and relied on setting parsedHeaders directly. This worked on the same
thread but failed when the response crossed a Comlink worker boundary,
since the transfer handler only serializes the raw headersStream — the
parsedHeaders property was lost.

Encode the headers into the headersStream using the JSON format that
parseHeadersStream expects, so headers survive Comlink serialization.
The parsedHeaders fast-path is kept for same-thread access.
Copilot AI review requested due to automatic review settings February 24, 2026 14:02
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes a regression where /wp-admin (no trailing slash) could render blank after crossing a Comlink worker boundary by ensuring response headers survive serialization.

Changes:

  • Serialize headers into headersStream using the JSON format expected by parseHeadersStream.
  • Keep parsedHeaders as a same-thread fast path to avoid re-parsing.
  • Add tests verifying header encoding (including multi-value headers) and cross-thread reconstruction.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.

File Description
packages/php-wasm/universal/src/lib/php-response.ts Writes headers into headersStream so Comlink transfer preserves redirects/status/headers.
packages/php-wasm/universal/src/lib/php-response.spec.ts Adds regression tests covering cross-thread header serialization and multi-value headers.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +141 to 145
// Set pre-parsed headers as a fast-path for same-thread
// access (avoids re-parsing the stream we just created)
streamed.parsedHeaders = Promise.resolve({
headers: response.headers,
httpStatusCode: response.httpStatusCode,
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

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

This introduces two sources of truth for headers (the JSON encoded into headersStream and the parsedHeaders fast-path). To prevent future divergence, consider generating both from a single shared representation/helper (e.g., one function that returns { headersJson, parsedHeaders }, or deriving parsedHeaders from the same normalized structure used to build headerLines).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Same as above — parsedHeaders is a cache, not a second source of truth. The stream carries the canonical data; the cached promise is a same-thread optimization set in the same call site.

Copy link
Copy Markdown
Collaborator

@adamziel adamziel Feb 26, 2026

Choose a reason for hiding this comment

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

I would still remove it @epeicher . This introduces a second data flow that compute the same result. This means that, from now on, we need to always keep the two code paths in sync. I guarantee that one day someone will update one code path without updating the other one. Re-parsing is very fast, probably under a millisecond. It's not a performance problem at all. Let's re-parse and have less places in code where we can make a mistake.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ok, let me work on a quick follow-up to avoid that 👍

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@adamziel @epeicher Btw., this is not directly related, but I noticed there is already a difference between same thread and Comlink usage. It seems that in parseHeadersStream, we lowercase the header name, while on a single thread, it remains untouched. Should we unify that too?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

That's an important point that has surfaced as part of the changes, with the removal of parseHeadersStream as part of #3310, this should be unified to always lowercase. Please review the changes in #3310 and let me know your view on them

@ashfame ashfame requested review from a team, JanJakes and fellyph and removed request for a team February 24, 2026 15:41
Copy link
Copy Markdown
Member

@JanJakes JanJakes left a comment

Choose a reason for hiding this comment

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

Looks good, thanks for fixing this! I have just small question.

headersStream,
stdout,
emptyStream(),
emptyStream,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

When at it, could we make fromPHPResponse also preserve stderr? I know there is no current usage of that (none of the instantiated responses have errors in them), but it would make the method more complete and universal.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

That makes total sense, thanks for the suggestion! I've added this commit 9783ad6 that encodes response.errors into the stderr stream instead of using an empty stream, and updated the existing tests to assert stderr is preserved through fromPHPResponse() and the round-trip.

Encode response.errors into the stderr stream instead of using an
empty stream, so stderr data survives Comlink worker boundary transfer.
Copy link
Copy Markdown
Member

@JanJakes JanJakes left a comment

Choose a reason for hiding this comment

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

Thank you!

@JanJakes JanJakes merged commit 5b715ec into WordPress:trunk Feb 25, 2026
42 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.

5 participants