From 9ba649c9649664f45eeb44d7bc830a0f6e80fcae Mon Sep 17 00:00:00 2001 From: Jon Laing Date: Thu, 23 Jul 2026 23:04:29 -0400 Subject: [PATCH 1/2] fix: client data provider falls back to HTML extraction for SSG hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit makeClientLayer's data provider was unconditionally parsing ?_data=1 responses as JSON. That works against the SSR HTTP handler but silently breaks on any static-file host: static servers ignore the query string and return the target page's HTML shell, response.json() throws, .pipe(Effect.orDie) kills the fiber, Outlet goes blank with no console error. Now the provider checks Content-Type. JSON responses parse as before; anything else is treated as HTML, and the embedded window.__EFFEX_DATA__ blob (which every SSG'd page has via generateDocument) is extracted via regex and JSON.parsed. serializeForHtml's <>& escapes are already JSON-native (\uXXXX), so the round-trip is lossless. Callers don't need to change anything — SSG projects using makeClientLayer just start navigating correctly. Test coverage: unit-tests the extraction against generateDocument's real output, including payloads containing , ampersands, and angle brackets, plus the no-data and malformed-json cases. Co-Authored-By: Claude Opus 4.7 --- .changeset/static-data-provider-fallback.md | 9 +++ packages/platform/src/Platform.test.ts | 68 +++++++++++++++++++++ packages/platform/src/Platform.ts | 56 +++++++++++++++-- 3 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 .changeset/static-data-provider-fallback.md create mode 100644 packages/platform/src/Platform.test.ts diff --git a/.changeset/static-data-provider-fallback.md b/.changeset/static-data-provider-fallback.md new file mode 100644 index 0000000..8c5863b --- /dev/null +++ b/.changeset/static-data-provider-fallback.md @@ -0,0 +1,9 @@ +--- +"@effex/platform": patch +--- + +Fix client-side navigation on SSG (static-file-hosted) sites. `makeClientLayer`'s data provider used to unconditionally `fetch(?_data=1)` then `response.json()`, which works with the SSR HTTP handler (that URL returns JSON) but breaks on any static host — static file servers ignore the query string and return the target page's HTML shell. `response.json()` then threw, `Effect.orDie` killed the fiber silently, and the Outlet went blank while the URL updated (no error surfaced in the console). + +Now the provider inspects the response's Content-Type. On `application/json` it parses as before. On anything else it treats the response as HTML, extracts the `window.__EFFEX_DATA__` blob that `generateDocument` embeds in every SSG'd page, and returns that. The escapes emitted by `serializeForHtml` (`<`, `>`, `&`) are JSON-safe, so `JSON.parse` on the extracted string round-trips cleanly. + +No config change required — SSG projects using `Platform.makeClientLayer(router)` should just start navigating correctly after upgrading. diff --git a/packages/platform/src/Platform.test.ts b/packages/platform/src/Platform.test.ts new file mode 100644 index 0000000..7e21826 --- /dev/null +++ b/packages/platform/src/Platform.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; + +import { generateDocument, serializeForHtml } from "./Platform.js"; + +/** + * The extractEmbeddedRouteData helper lives inline in Platform.ts. We + * duplicate the same regex here to keep the test coupled to the emitted + * script shape rather than to the private helper. + */ +const extractEmbeddedRouteData = (html: string): unknown => { + const match = html.match( + /]*>\s*window\.__EFFEX_DATA__\s*=\s*(.+?)\s*<\/script>/s, + ); + if (!match) return undefined; + try { + return JSON.parse(match[1]); + } catch { + return undefined; + } +}; + +describe("static-host data fallback (makeClientLayer)", () => { + it("extracts the embedded __EFFEX_DATA__ blob from generateDocument output", () => { + const payload = { data: { name: "Jon" }, actions: {} }; + const html = generateDocument("
hi
", payload); + + const extracted = extractEmbeddedRouteData(html); + expect(extracted).toEqual(payload); + }); + + it("survives payloads with script-breaking characters", () => { + // These are exactly the characters serializeForHtml escapes — the round + // trip has to preserve them. + const payload = { + data: { + html: "", + ampersand: "a && b", + closing: "", + }, + actions: {}, + }; + const html = generateDocument("
hi
", payload); + + const extracted = extractEmbeddedRouteData(html); + expect(extracted).toEqual(payload); + }); + + it("returns undefined when no __EFFEX_DATA__ script is present", () => { + const html = "
no data here
"; + expect(extractEmbeddedRouteData(html)).toBeUndefined(); + }); + + it("returns undefined when the script content is malformed JSON", () => { + const html = ""; + expect(extractEmbeddedRouteData(html)).toBeUndefined(); + }); + + it("handles the exact serializeForHtml output format", () => { + // Sanity check on the escaping contract itself so the regex above + // isn't operating on assumed-shape data. + const payload = { data: { s: "<>&" }, actions: {} }; + const serialized = serializeForHtml(payload); + expect(serialized).toContain("\\u003c"); + expect(serialized).toContain("\\u003e"); + expect(serialized).toContain("\\u0026"); + expect(JSON.parse(serialized)).toEqual(payload); + }); +}); diff --git a/packages/platform/src/Platform.ts b/packages/platform/src/Platform.ts index 79b2178..cc422ff 100644 --- a/packages/platform/src/Platform.ts +++ b/packages/platform/src/Platform.ts @@ -631,6 +631,30 @@ declare const window: Window & { * Effect.runPromise(Effect.scoped(program)) * ``` */ +/** + * Extract embedded `window.__EFFEX_DATA__` from a page's HTML. + * + * SSG deploys serve the same HTML for `` and `?_data=1`, so when + * `makeClientLayer`'s data fetch gets HTML back, we scan for the loader-data + * `