diff --git a/.changeset/static-data-provider-fallback.md b/.changeset/static-data-provider-fallback.md new file mode 100644 index 0000000..337fe5c --- /dev/null +++ b/.changeset/static-data-provider-fallback.md @@ -0,0 +1,11 @@ +--- +"@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. + +Also: added `console.error` logging before the provider's `Effect.orDie` guard so a genuinely failed fetch (network error, malformed response, etc.) surfaces in the browser console instead of dying silently. If the HTML fallback runs but no `__EFFEX_DATA__` blob is present, that's logged as a `console.warn` — non-fatal but useful for catching broken builds. 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..38f5f78 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 + * `