Skip to content

Regression from #1135: App Router pages with GTM URLs in RSC data fail to serve #1206

Description

@aram356

Description

Since #1135 (commit a4e01eb, merged 2026-09-24), Trusted Server can no longer serve some Next.js App Router pages. The trigger is a page whose React Server Components data (the self.__next_f.push(...) flight scripts) contains a googletagmanager.com or google-analytics.com URL, on a deployment that enables both [integrations.nextjs] and [integrations.google_tag_manager]. The HTML transform fails partway through the document:

  • Fastly: the client gets 200 OK and the headers, then the body stream is abandoned. With a small page, no body bytes arrive at all.
  • Axum: 502 Bad Gateway with the body An internal error occurred. Cloudflare and Spin use the same buffered path and the same error mapping, so by code reading they return the same 502. I did not run those two.

Loading gtag with next/script is enough to trigger it. Take this Next.js 14.2.33 App Router root layout:

import Script from "next/script";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        <Script src="https://www.googletagmanager.com/gtag/js?id=G-TEST" strategy="afterInteractive" />
      </body>
    </html>
  );
}

next build serializes the client component's props into the page's flight data:

self.__next_f.push([1,"…[\"$\",\"$L5\",null,{\"src\":\"https://www.googletagmanager.com/gtag/js?id=G-TEST\",\"strategy\":\"afterInteractive\"}]…"])

Served through Trusted Server at a4e01eb with both integrations enabled, that page comes back as 200 OK with an empty, aborted body on Fastly (Viceroy) and as a 502 on Axum. The same happens in two other cases:

  • A layout that passes the standard GTM snippet to next/script as inline children.
  • The repository's own App Router fixture crates/trusted-server-core/src/html_processor.test.html. Its flight data carries https://www.googletagmanager.com/ns.html?id=… for a <noscript> iframe.

The same inputs work at 4c6d26a, the commit before #1135: they are served completely, with both the Next.js and the GTM rewrite applied. At a4e01eb they also work when only one of the two integrations is enabled. CI did not catch this. The App Router fixture test in google_tag_manager.rs (test_html_processing_with_fixture) enables GTM without Next.js. The only core tests that enable both integrations (fragmented_next_data_survives_with_gtm_enabled and fragmented_next_data_with_trailing_g_survives_gtm) use __NEXT_DATA__ pages with no GTM URL in them.

Steps to reproduce

Unit tests (repository only). Add these tests to mod tests in crates/trusted-server-core/src/integrations/google_tag_manager.rs, next to test_html_processing_with_fixture. The module already has make_settings, config_from_settings, create_html_processor, IntegrationRegistry and the streaming pipeline types in scope.

    /// An App Router flight script that mentions a GTM URL must still be
    /// processed, and the Next.js rewrite inside it must survive.
    #[test]
    fn rsc_flight_script_containing_gtm_url_is_processed() {
        use crate::streaming_processor::{Compression, PipelineConfig, StreamingPipeline};
        use std::io::Cursor;

        let mut settings = make_settings();
        settings
            .integrations
            .insert_config(
                "google_tag_manager",
                &serde_json::json!({ "enabled": true, "container_id": "GTM-ABC123" }),
            )
            .expect("should update gtm config");
        settings
            .integrations
            .insert_config(
                "nextjs",
                &serde_json::json!({ "enabled": true, "rewrite_attributes": ["href", "link", "url"] }),
            )
            .expect("should update nextjs config");
        let registry = IntegrationRegistry::with_plan(
            &settings,
            Arc::new(
                crate::auction::compile_auction_plan(&settings)
                    .expect("should compile auction plan"),
            ),
        )
        .expect("should create registry");
        let html = r#"<html><body><script>self.__next_f.push([1,"{\"href\":\"https://origin.example.com/app\",\"gtm\":\"https://www.googletagmanager.com/gtm.js?id=GTM-ABC123\"}"])</script></body></html>"#;

        let processor = create_html_processor(config_from_settings(&settings, &registry));
        let mut pipeline = StreamingPipeline::new(
            PipelineConfig {
                input_compression: Compression::None,
                output_compression: Compression::None,
                chunk_size: 8192,
            },
            processor,
        );
        let mut output = Vec::new();
        pipeline
            .process(Cursor::new(html.as_bytes()), &mut output)
            .expect("should process an App Router page that mentions GTM");
        let processed = String::from_utf8(output).expect("should produce UTF-8");

        assert!(
            processed.contains(r#"\"href\":\"https://test.example.com/app\""#),
            "should keep the Next.js RSC rewrite: {processed}"
        );
    }

    /// The repository's App Router fixture carries a GTM URL inside Next.js
    /// flight data. With both integrations enabled it must still be processed.
    #[test]
    fn html_fixture_with_gtm_and_nextjs_is_processed() {
        use std::io::Cursor;

        let mut settings = make_settings();
        settings
            .integrations
            .insert_config(
                "google_tag_manager",
                &serde_json::json!({ "enabled": true, "container_id": "GTM-522ZT3X6" }),
            )
            .expect("should update gtm config");
        settings
            .integrations
            .insert_config(
                "nextjs",
                &serde_json::json!({ "enabled": true, "rewrite_attributes": ["href", "link", "url"] }),
            )
            .expect("should update nextjs config");
        let registry = IntegrationRegistry::with_plan(
            &settings,
            Arc::new(
                crate::auction::compile_auction_plan(&settings)
                    .expect("should compile auction plan"),
            ),
        )
        .expect("should create registry");
        let processor = create_html_processor(config_from_settings(&settings, &registry));
        let mut pipeline = StreamingPipeline::new(
            PipelineConfig {
                input_compression: Compression::None,
                output_compression: Compression::None,
                chunk_size: 8192,
            },
            processor,
        );
        let html_content = include_str!("../html_processor.test.html");

        let mut output = Vec::new();
        pipeline
            .process(Cursor::new(html_content.as_bytes()), &mut output)
            .expect("should process the App Router fixture with GTM and Next.js enabled");
        let processed = String::from_utf8(output).expect("should produce UTF-8");

        assert!(
            processed.contains("/integrations/google_tag_manager/gtm.js?id=GTM-522ZT3X6"),
            "should still rewrite the GTM preload link"
        );
    }
cargo test -p trusted-server-core --target <host-triple> --lib -- \
  rsc_flight_script_containing_gtm_url_is_processed html_fixture_with_gtm_and_nextjs_is_processed

Observed at a4e01eb, both tests fail:

---- integrations::google_tag_manager::tests::rsc_flight_script_containing_gtm_url_is_processed stdout ----
should process an App Router page that mentions GTM: Proxy error: Failed to process final chunk
╰─▶ Next.js RSC captured payload was not present in parser output

---- integrations::google_tag_manager::tests::html_fixture_with_gtm_and_nextjs_is_processed stdout ----
should process the App Router fixture with GTM and Next.js enabled: Proxy error: Failed to process chunk
╰─▶ Next.js RSC output contains an unknown generated placeholder

Both tests pass at 4c6d26a.

End to end on the Fastly adapter (Viceroy). This uses the integration-test config generator.

  1. Build a Next.js 14.2.33 app with the layout above and src/app/page.tsx containing export default function Page() { return <a href="https://origin.example.com/reviews">Reviews</a>; }. Run next build, then copy .next/server/app/index.html to origin/gtag.html.
  2. cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
  3. Copy crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml to app.toml. Set enabled = true under [integrations.nextjs] and [integrations.google_tag_manager], and delete the GTM upstream_url line.
  4. Build generate-viceroy-config (cargo build --manifest-path crates/trusted-server-integration-tests/Cargo.toml --target <host-triple> --bin generate-viceroy-config) and run it with --template crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml --app-config app.toml --output viceroy.toml --origin-url http://127.0.0.1:8899.
  5. In origin/, run python3 -m http.server 8899 --bind 127.0.0.1.
  6. viceroy target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm -C viceroy.toml --addr 127.0.0.1:7801
  7. curl -sS -D - -o out.html -H 'Accept: text/html' -H 'Sec-Fetch-Dest: document' -H 'Sec-Fetch-Mode: navigate' http://127.0.0.1:7801/gtag.html

Observed with a4e01eb (Viceroy 0.21.0):

HTTP/1.1 200 OK
content-type: text/html
...
transfer-encoding: chunked

curl: (18) transfer closed with outstanding read data remaining

out.html stays empty. Viceroy logs EdgeZero streaming failed: Proxy error: streaming platform response body failed: Proxy error: Failed to process chunk … Next.js RSC output contains an unknown generated placeholder.

The same harness with other pages, configurations and builds:

Build and integrations gtag via next/script src GTM snippet as next/script children html_processor.test.html (554,355 bytes)
a4e01eb, GTM + Next.js 200, 0 body bytes, aborted 200, 0 bytes, aborted 200, 157,861 bytes, aborted
a4e01eb, Next.js only 200, 3,853 bytes, complete 200, 4,071 bytes, complete 200, 554,500 bytes, complete
a4e01eb, GTM only 200, 3,854 bytes, complete 200, 4,071 bytes, complete 200, 554,503 bytes, complete
4c6d26a, GTM + Next.js 200, 3,854 bytes, complete 200, 4,071 bytes, complete 200, 554,503 bytes, complete

At 4c6d26a the flight data came out with GTM's rewrite of the gtag URL (/integrations/google_tag_manager/gtag/js?id=G-TEST). When I ran the same page through the core pipeline with origin.example.com as the origin host, the Next.js rewrite of the page's link was applied as well.

Axum. I served the same three pages from a stub upstream through the Axum router (TrustedServerApp::routes_with_settings_and_services), which is what the Axum dev server runs, at a4e01eb. With GTM and Next.js enabled, all three returned 502 Bad Gateway, content-type: text/plain; charset=utf-8, body An internal error occurred\n. With only Next.js enabled, all three returned 200 with the processed page.

Expected behavior

With both integrations enabled, an App Router page whose flight data mentions GTM or GA is served completely with status 200, and the Next.js rewrite of its flight data is applied.

Actual behavior

  • The HTML transform returns an error for the document.
  • On Fastly the response has already been committed as 200 OK, so the client receives a truncated or empty body.
  • On Axum the client receives 502 Bad Gateway with An internal error occurred.

Root cause

Three script rewriters match a flight script: Next.js's script#__NEXT_DATA__ (which does not apply here), Next.js's NextJsRscPlaceholderRewriter on script, and GTM on script. create_html_processor gives each its own lol_html text handler that applies its action directly to the shared chunk (html_processor.rs:664-694). In lol_html 2.9.0 every handler reads the original chunk text, and for each chunk the last replace() wins. GTM is registered after Next.js (integrations/mod.rs:302-305 and 326-329).

#1135 changed how the placeholder rewriter works:

  • It now captures each flight payload during the parse and replaces it with a generated placeholder (nextjs/rsc_placeholders.rs:96-106).
  • A post-parser stream processor must then find those placeholders in the parser output, in order. If a placeholder is missing or out of order, it returns an error for the whole document (nextjs/rsc_stream.rs:467-470, Next.js RSC output contains an unknown generated placeholder, and :521-524, Next.js RSC captured payload was not present in parser output).
  • Before Make body hold parser-aware and stream Next.js processing #1135 the placeholder rewriter returned Keep on every intermediate fragment and relied on a post-processor that re-parsed the final HTML. See the comment at rsc_placeholders.rs:57-62 in 4c6d26a.

GTM buffers every script that contains its hosts and, on the last fragment, replaces it with Self::rewrite_gtm_urls(text) built from the original text (google_tag_manager.rs:1045-1077). Because GTM runs last, its replacement discards the placeholder text. The stream processor then finds a placeholder missing and fails.

How the error reaches the client:

  • Fastly: the adapter calls publisher_response_into_streaming_response (trusted-server-adapter-fastly/src/app.rs:906-915). That function returns the origin status and headers with a lazy body. The processing error surfaces inside that body stream through publisher_stream_error (publisher.rs:275-277, 2707-2727). send_edgezero_response has already called stream_to_client(). On the error it logs EdgeZero streaming failed and drops the StreamingBody without finish() (trusted-server-adapter-fastly/src/main.rs:346-363). The fastly 0.12.1 crate documents: "A streaming body will be automatically aborted if it goes out of scope without calling finish()."
  • Axum: buffer_publisher_response_async returns TrustedServerError::Proxy. execute_handler maps it through http_error (trusted-server-adapter-axum/src/app.rs:118-130, 170-172), and IntoHttpResponse maps Proxy to 502 Bad Gateway with the generic message An internal error occurred (error.rs:128, 147).
  • Cloudflare and Spin: they call the same buffer_publisher_response_async and have identical http_error functions (trusted-server-adapter-cloudflare/src/app.rs:256, 274-286; trusted-server-adapter-spin/src/app.rs:173, 455-467).

Impact

  • Affected deployments: those that enable both [integrations.nextjs] (off by default) and [integrations.google_tag_manager] and serve App Router pages. The failure needs a GTM or GA URL inside flight data. Verified ways to get one: a next/script tag with a GTM or gtag src, the GTM snippet passed to next/script as children, and a server-rendered <noscript> GTM iframe (the repository fixture). A component that builds the URL in the browser from an ID alone should not put the host into flight data, but I did not test one.
  • What visitors get: on Fastly, a blank page or a page cut off where the failing chunk started, because the transfer is aborted after 200 OK. On the other adapters, a 502 error page. Monitoring that counts status codes sees 200 on Fastly.
  • Pages Router: pages that use __NEXT_DATA__ do not hit this error. They have separate problems, tracked in GTM script rewriter clobbers Next.js __NEXT_DATA__ rewrites and can truncate it #1208 and Regression from #1135: __NEXT_DATA__ is corrupted when script text splits after _ #1207.
  • Likelihood: every page I tried with a GTM or GA URL in its flight data failed at the production chunk size. The synthetic single-script test does not fail with 32-byte chunks, so the outcome depends on where fragment boundaries fall. The common case fails, though.

Proposed fix

Compose script rewriters in core instead of letting lol_html merge independent handlers. This is the change proposed in #1208: lol_html still matches selectors, and one text!("script") handler pipes each chunk through the matching rewriters in registration order and applies a single mutation. GTM then works on the placeholder rewriter's output, so the placeholders survive.

I prototyped it in a scratch copy of a4e01eb:

  • Both tests above pass.
  • The three pages in the table are served completely under Viceroy with both integrations enabled (200, 3,854, 4,071 and 554,503 bytes).
  • Every existing test in a native cargo test -p trusted-server-core --lib run passes.

One behavior depends on order. In today's order (Next.js first), GTM sees placeholders, so a GTM URL inside flight data stays third-party: the prototype left {"src":"https://www.googletagmanager.com/gtag/js?id=G-TEST"} unchanged. GTM's attribute rewriter still rewrote the <link rel="preload"> Next.js emits for that script. Before #1135 the flight URL was rewritten. Running GTM's script rewriter ahead of Next.js's restores that. The prototype did so on the real page, together with the GTM buffering fix described in #1208.

Reordering alone, without composition, is not enough. It avoided the error on the real page, but GTM's rewrite of the flight URL was lost, and the two rewriters would still act on the same fragments independently.

A defensive follow-up, which I have not tested: when a captured placeholder is missing, the RSC stream processor could release the parser output unchanged and log a warning instead of failing the whole document. Any other script rewriter could otherwise trigger the same failure.

Done when

  • rsc_flight_script_containing_gtm_url_is_processed and html_fixture_with_gtm_and_nextjs_is_processed pass.
  • An adapter-level test serves an App Router page with a GTM URL in its flight data, with GTM and Next.js enabled, and gets 200 with complete HTML.
  • Whether GTM URLs inside flight data are rewritten is decided and pinned by a test.
  • Existing Next.js streaming and GTM tests still pass.

Affected area

HTML processing / JS injection

Version

main at a4e01eb

Related

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

No labels
No labels

Type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions