Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/static-data-provider-fallback.md
Original file line number Diff line number Diff line change
@@ -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(<path>?_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.
68 changes: 68 additions & 0 deletions packages/platform/src/Platform.test.ts
Original file line number Diff line number Diff line change
@@ -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(
/<script[^>]*>\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("<div>hi</div>", 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: "<script>alert('xss')</script>",
ampersand: "a && b",
closing: "</script>",
},
actions: {},
};
const html = generateDocument("<div>hi</div>", payload);

const extracted = extractEmbeddedRouteData(html);
expect(extracted).toEqual(payload);
});

it("returns undefined when no __EFFEX_DATA__ script is present", () => {
const html = "<html><body><div>no data here</div></body></html>";
expect(extractEmbeddedRouteData(html)).toBeUndefined();
});

it("returns undefined when the script content is malformed JSON", () => {
const html = "<script>window.__EFFEX_DATA__=not-json</script>";
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);
});
});
83 changes: 77 additions & 6 deletions packages/platform/src/Platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<path>` and `<path>?_data=1`, so when
* `makeClientLayer`'s data fetch gets HTML back, we scan for the loader-data
* `<script>` tag emitted by {@link generateLoaderDataScript}. The JSON inside
* uses \\uXXXX escapes for script-breaking characters (see {@link serializeForHtml}),
* which JSON.parse handles natively.
*/
const extractEmbeddedRouteData = (html: string): unknown => {
const match = html.match(
/<script[^>]*>\s*window\.__EFFEX_DATA__\s*=\s*(.+?)\s*<\/script>/s,
);
if (!match) return undefined;
try {
return JSON.parse(match[1]);
} catch {
return undefined;
}
};

const isJsonContentType = (response: Response): boolean =>
(response.headers.get("content-type") ?? "").toLowerCase().includes("json");

export const makeClientLayer = <
P extends Record<string, unknown> | never,
S extends Record<string, unknown> | never,
Expand Down Expand Up @@ -665,20 +689,67 @@ export const makeClientLayer = <
}
}

// Client navigation: fetch from server
// Client navigation: fetch data for the target path.
//
// On an SSR server this hits the `?_data=1` handler and returns
// JSON. On a static host (SSG output on any file server) the
// query string is ignored and the same page HTML comes back — we
// fall back to extracting the embedded `window.__EFFEX_DATA__`
// from the HTML shell. Both modes look the same to callers.
const path = substituteParams(route.path, params);
const qs = new URLSearchParams(searchParams);
qs.set("_data", "1");
const response = yield* Effect.tryPromise(() =>
fetch(`${path}?${qs.toString()}`),
);

const json = yield* Effect.tryPromise(() => response.json());
if (isJsonContentType(response)) {
const json = yield* Effect.tryPromise(() => response.json());
// Pass through as-is — if it's a redirect signal
// ({ _redirect: url }), the Outlet handles it via nav.pushPath
return json as unknown as RouteDataService;
}

// Pass through as-is — if it's a redirect signal ({ _redirect: url }),
// the Outlet handles it via nav.pushPath
return json as unknown as RouteDataService;
}).pipe(Effect.orDie),
// Static fallback: extract from the served HTML shell. The
// embedded blob has shape `{ data, actions }` (see buildStaticSite's
// hydrationData construction); we pass it through unchanged after
// adding a synthetic loaderPath.
const html = yield* Effect.tryPromise(() => response.text());
const embeddedBlob = extractEmbeddedRouteData(html) as
| { data: unknown; actions?: Record<string, unknown> }
| undefined;
if (embeddedBlob === undefined) {
// Static host served HTML but we couldn't find the data blob.
// Not fatal — some routes may legitimately have no loader data
// — but log it so a broken build/deploy doesn't sit invisible.
// eslint-disable-next-line no-console
console.warn(
`[@effex/platform] Fetched HTML for ${path} but couldn't find window.__EFFEX_DATA__. ` +
`Continuing with data: undefined.`,
);
}
const loaderPath = `${path}?${qs.toString()}`;
return {
data: embeddedBlob?.data,
loaderPath,
actions: embeddedBlob?.actions ?? {},
} as unknown as RouteDataService;
}).pipe(
// Log before Effect.orDie so a failed fetch/parse doesn't become
// an invisible blank Outlet. Devs see the cause in the console;
// error trackers (Sentry, etc.) still catch it via console.error.
Effect.tapError((err) =>
Effect.sync(() => {
// eslint-disable-next-line no-console
console.error(
`[@effex/platform] Failed to load route data for ${substituteParams(route.path, params)}. ` +
`Outlet will render empty.`,
err,
);
}),
),
Effect.orDie,
),
};

return provider;
Expand Down
Loading