@bractjs/bractjs 0.1.27 → 0.1.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +242 -36
  2. package/bin/cli.ts +18 -1
  3. package/package.json +1 -1
  4. package/src/__tests__/codegen-write.test.ts +67 -0
  5. package/src/__tests__/codegen.test.ts +29 -2
  6. package/src/__tests__/compile-safety.test.ts +4 -0
  7. package/src/__tests__/csp.test.ts +10 -0
  8. package/src/__tests__/define-actions.test.ts +69 -0
  9. package/src/__tests__/env.test.ts +18 -0
  10. package/src/__tests__/fetcher-store.test.ts +67 -0
  11. package/src/__tests__/fixtures/app/root.tsx +7 -2
  12. package/src/__tests__/fixtures/app/routes/boom.tsx +9 -0
  13. package/src/__tests__/fixtures/app/routes/client-only.tsx +16 -0
  14. package/src/__tests__/fixtures/app/routes/counter.tsx +16 -0
  15. package/src/__tests__/fixtures/app/routes/data-only.tsx +16 -0
  16. package/src/__tests__/fixtures/app/routes/intent-demo.tsx +46 -0
  17. package/src/__tests__/fixtures/app/routes/protected-client-only.tsx +15 -0
  18. package/src/__tests__/fixtures/app/routes/search-demo.tsx +39 -0
  19. package/src/__tests__/form-data-helpers.test.ts +43 -0
  20. package/src/__tests__/integration.test.ts +56 -0
  21. package/src/__tests__/loader.test.ts +32 -1
  22. package/src/__tests__/nav-utils.test.ts +46 -0
  23. package/src/__tests__/prerender.test.ts +102 -0
  24. package/src/__tests__/programmatic-api.test.ts +20 -1
  25. package/src/__tests__/revalidation.test.ts +65 -0
  26. package/src/__tests__/route-lint.test.ts +74 -0
  27. package/src/__tests__/route-table.test.ts +33 -0
  28. package/src/__tests__/safe-validate.test.ts +96 -0
  29. package/src/__tests__/scroll-restoration.test.ts +66 -0
  30. package/src/__tests__/search-serializer.test.ts +42 -0
  31. package/src/__tests__/search-validation.test.ts +125 -0
  32. package/src/__tests__/security.test.ts +110 -1
  33. package/src/__tests__/selective-ssr.test.ts +85 -0
  34. package/src/__tests__/spa-mode.test.ts +77 -0
  35. package/src/__tests__/typed-routing.test.ts +51 -1
  36. package/src/build/bundler.ts +33 -0
  37. package/src/build/prerender.ts +88 -0
  38. package/src/build/route-lint.ts +49 -0
  39. package/src/client/ClientRouter.tsx +239 -47
  40. package/src/client/cache.ts +8 -0
  41. package/src/client/components/Await.tsx +9 -2
  42. package/src/client/components/Form.tsx +23 -34
  43. package/src/client/components/Link.tsx +80 -9
  44. package/src/client/components/Outlet.tsx +8 -2
  45. package/src/client/components/ScrollRestoration.tsx +125 -0
  46. package/src/client/entry.tsx +39 -2
  47. package/src/client/fetcher-store.ts +61 -0
  48. package/src/client/form-utils.ts +3 -0
  49. package/src/client/hooks/useActionData.ts +7 -3
  50. package/src/client/hooks/useFetcher.ts +116 -33
  51. package/src/client/hooks/useFetchers.ts +23 -0
  52. package/src/client/hooks/useLoaderData.ts +8 -4
  53. package/src/client/hooks/useLocation.ts +27 -0
  54. package/src/client/hooks/useNavigate.ts +11 -6
  55. package/src/client/hooks/useRevalidator.ts +26 -0
  56. package/src/client/hooks/useSearch.ts +73 -0
  57. package/src/client/hooks/useSearchParams.ts +7 -2
  58. package/src/client/nav-utils.ts +26 -0
  59. package/src/client/prefetch.ts +110 -15
  60. package/src/client/registry.ts +24 -0
  61. package/src/client/revalidation.ts +25 -0
  62. package/src/client/router.tsx +28 -1
  63. package/src/client/scroll-restoration.ts +48 -0
  64. package/src/client/search-serializer.ts +40 -0
  65. package/src/client/types.ts +6 -0
  66. package/src/codegen/route-codegen.ts +141 -8
  67. package/src/config/load.ts +21 -0
  68. package/src/dev/hmr-client.ts +3 -1
  69. package/src/dev/route-table.ts +27 -0
  70. package/src/dev/server.ts +106 -8
  71. package/src/dev/watcher.ts +25 -3
  72. package/src/index.ts +27 -3
  73. package/src/server/action-handler.ts +12 -3
  74. package/src/server/action-registry.ts +35 -0
  75. package/src/server/csp.ts +10 -1
  76. package/src/server/csrf.ts +26 -0
  77. package/src/server/env.ts +26 -5
  78. package/src/server/layout.ts +31 -1
  79. package/src/server/loader.ts +14 -8
  80. package/src/server/render.ts +18 -3
  81. package/src/server/request-handler.ts +50 -8
  82. package/src/server/search.ts +43 -0
  83. package/src/server/serve.ts +88 -1
  84. package/src/server/spa.ts +62 -0
  85. package/src/server/stream-handler.ts +10 -1
  86. package/src/server/validate.ts +85 -13
  87. package/src/shared/context.ts +5 -0
  88. package/src/shared/define-actions.ts +39 -0
  89. package/src/shared/form-data.ts +34 -0
  90. package/src/shared/route-types.ts +83 -2
  91. package/templates/new-app/app/root.tsx +2 -1
  92. package/templates/new-app/bractjs.config.ts +7 -12
  93. package/types/config.d.ts +21 -0
  94. package/types/index.d.ts +165 -9
  95. package/types/route.d.ts +62 -2
@@ -54,6 +54,16 @@ describe("csp middleware", () => {
54
54
  expect(res.headers.get("Content-Security-Policy")).not.toContain("object-src");
55
55
  });
56
56
 
57
+ test("default style-src allows 'unsafe-inline'; strict drops it", async () => {
58
+ const def = await runCsp(csp(), () => Promise.resolve(new Response("ok")));
59
+ expect(def.res.headers.get("Content-Security-Policy")).toContain("style-src 'self' 'unsafe-inline'");
60
+
61
+ const strict = await runCsp(csp({ strict: true }), () => Promise.resolve(new Response("ok")));
62
+ const policy = strict.res.headers.get("Content-Security-Policy")!;
63
+ expect(policy).toContain("style-src 'self'");
64
+ expect(policy).not.toContain("'unsafe-inline'");
65
+ });
66
+
57
67
  test("reportOnly emits the report-only header instead", async () => {
58
68
  const { res } = await runCsp(csp({ reportOnly: true }), () => Promise.resolve(new Response("ok")));
59
69
  expect(res.headers.get("Content-Security-Policy-Report-Only")).toBeTruthy();
@@ -0,0 +1,69 @@
1
+ import { test, expect, describe, afterEach } from "bun:test";
2
+ import { defineActions } from "../shared/define-actions.ts";
3
+ import type { ActionArgs } from "../shared/route-types.ts";
4
+
5
+ function argsWith(intent?: string, extra: Record<string, string> = {}): ActionArgs {
6
+ const fd = new FormData();
7
+ if (intent !== undefined) fd.set("intent", intent);
8
+ for (const [k, v] of Object.entries(extra)) fd.set(k, v);
9
+ return {
10
+ request: new Request("http://localhost/"),
11
+ params: {},
12
+ context: {},
13
+ search: {},
14
+ formData: fd,
15
+ };
16
+ }
17
+
18
+ const ORIGINAL_ENV = Bun.env.NODE_ENV;
19
+ afterEach(() => {
20
+ if (ORIGINAL_ENV === undefined) delete Bun.env.NODE_ENV;
21
+ else Bun.env.NODE_ENV = ORIGINAL_ENV;
22
+ });
23
+
24
+ describe("defineActions", () => {
25
+ test("dispatches to the matching handler with full args", async () => {
26
+ let seen: ActionArgs | null = null;
27
+ const action = defineActions({
28
+ add: (args) => { seen = args; return { ok: true, who: "add" }; },
29
+ remove: () => ({ ok: true, who: "remove" }),
30
+ });
31
+ const result = await action(argsWith("add", { title: "x" }));
32
+ expect(result).toEqual({ ok: true, who: "add" });
33
+ expect(seen!.formData.get("title")).toBe("x");
34
+ });
35
+
36
+ test("unknown intent → 400 listing known intents in dev", async () => {
37
+ Bun.env.NODE_ENV = "development";
38
+ const action = defineActions({ add: () => ({}), remove: () => ({}) });
39
+ const res = await action(argsWith("nope"));
40
+ expect(res).toBeInstanceOf(Response);
41
+ expect((res as Response).status).toBe(400);
42
+ const body = await (res as Response).json() as { error: string };
43
+ expect(body.error).toContain("add");
44
+ expect(body.error).toContain("remove");
45
+ expect(body.error).toContain("nope");
46
+ });
47
+
48
+ test("unknown intent → terse 400 in production", async () => {
49
+ Bun.env.NODE_ENV = "production";
50
+ const action = defineActions({ add: () => ({}) });
51
+ const res = await action(argsWith("nope")) as Response;
52
+ expect(res.status).toBe(400);
53
+ const body = await res.json() as { error: string };
54
+ expect(body.error).toBe("Unknown action intent.");
55
+ });
56
+
57
+ test("missing intent → 400", async () => {
58
+ const action = defineActions({ add: () => ({}) });
59
+ const res = await action(argsWith(undefined)) as Response;
60
+ expect(res.status).toBe(400);
61
+ });
62
+
63
+ test("awaits async handlers", async () => {
64
+ const action = defineActions({
65
+ slow: async () => { await Promise.resolve(); return { done: true }; },
66
+ });
67
+ expect(await action(argsWith("slow"))).toEqual({ done: true });
68
+ });
69
+ });
@@ -45,6 +45,24 @@ describe("safeStringify", () => {
45
45
  expect(parsed.self).toBe("[Circular]");
46
46
  });
47
47
 
48
+ // Regression: a SHARED (non-cyclic) reference must serialize normally. The
49
+ // old WeakSet-of-everything approach flagged the second occurrence as
50
+ // circular — which corrupted __BRACTJS_DATA__ whenever a loader echoed
51
+ // `args.search` (also present at the payload's top level).
52
+ test("shared references are not flagged as circular", () => {
53
+ const shared = { page: 5 };
54
+ const out = safeStringify({ a: shared, b: { inner: shared } });
55
+ expect(JSON.parse(out)).toEqual({ a: { page: 5 }, b: { inner: { page: 5 } } });
56
+ });
57
+
58
+ test("deep cycles through arrays are still caught", () => {
59
+ const arr: unknown[] = [];
60
+ const obj = { arr };
61
+ arr.push(obj);
62
+ const parsed = JSON.parse(safeStringify(obj)) as { arr: string[] };
63
+ expect(parsed.arr[0]).toBe("[Circular]");
64
+ });
65
+
48
66
  test("handles nested objects", () => {
49
67
  const out = safeStringify({ a: { b: { c: 42 } } });
50
68
  expect(JSON.parse(out)).toEqual({ a: { b: { c: 42 } } });
@@ -0,0 +1,67 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { fetcherStore, EMPTY_FETCHERS } from "../client/fetcher-store.ts";
3
+
4
+ // The store is a module-level singleton — use unique keys per test so cases
5
+ // stay independent.
6
+
7
+ describe("fetcherStore", () => {
8
+ test("update creates an entry with idle defaults merged", () => {
9
+ fetcherStore.update("t1", { state: "submitting", formMethod: "POST" });
10
+ const entry = fetcherStore.get("t1");
11
+ expect(entry).toEqual({
12
+ key: "t1",
13
+ state: "submitting",
14
+ data: undefined,
15
+ formMethod: "POST",
16
+ });
17
+ fetcherStore.remove("t1");
18
+ });
19
+
20
+ test("partial updates preserve other fields", () => {
21
+ fetcherStore.update("t2", { state: "submitting", formMethod: "DELETE" });
22
+ fetcherStore.update("t2", { data: { ok: true } });
23
+ const entry = fetcherStore.get("t2")!;
24
+ expect(entry.state).toBe("submitting");
25
+ expect(entry.formMethod).toBe("DELETE");
26
+ expect(entry.data).toEqual({ ok: true });
27
+ fetcherStore.remove("t2");
28
+ });
29
+
30
+ test("subscribe fires on update and remove; unsubscribe stops it", () => {
31
+ let calls = 0;
32
+ const unsub = fetcherStore.subscribe(() => calls++);
33
+ fetcherStore.update("t3", { state: "loading" });
34
+ expect(calls).toBe(1);
35
+ fetcherStore.remove("t3");
36
+ expect(calls).toBe(2);
37
+ unsub();
38
+ fetcherStore.update("t3b", { state: "loading" });
39
+ expect(calls).toBe(2);
40
+ fetcherStore.remove("t3b");
41
+ });
42
+
43
+ test("removing a missing key does not notify", () => {
44
+ let calls = 0;
45
+ const unsub = fetcherStore.subscribe(() => calls++);
46
+ fetcherStore.remove("never-existed");
47
+ expect(calls).toBe(0);
48
+ unsub();
49
+ });
50
+
51
+ test("snapshot is referentially stable between updates (useSyncExternalStore contract)", () => {
52
+ fetcherStore.update("t4", { state: "idle" });
53
+ const a = fetcherStore.getSnapshot();
54
+ const b = fetcherStore.getSnapshot();
55
+ expect(a).toBe(b);
56
+ fetcherStore.update("t4", { state: "loading" });
57
+ const c = fetcherStore.getSnapshot();
58
+ expect(c).not.toBe(a);
59
+ expect(c.find((e) => e.key === "t4")?.state).toBe("loading");
60
+ fetcherStore.remove("t4");
61
+ });
62
+
63
+ test("EMPTY_FETCHERS is a stable empty server snapshot", () => {
64
+ expect(EMPTY_FETCHERS).toEqual([]);
65
+ expect(EMPTY_FETCHERS).toBe(EMPTY_FETCHERS);
66
+ });
67
+ });
@@ -1,9 +1,14 @@
1
- // Minimal root component for integration tests.
1
+ // Minimal root component for integration tests. Renders the matched route
2
+ // through <Outlet/> so body-level SSR assertions (components, Fallbacks) work.
3
+ import { Outlet } from "../../../client/components/Outlet.tsx";
4
+
2
5
  export default function Root() {
3
6
  return (
4
7
  <html>
5
8
  <head></head>
6
- <body></body>
9
+ <body>
10
+ <Outlet />
11
+ </body>
7
12
  </html>
8
13
  );
9
14
  }
@@ -0,0 +1,9 @@
1
+ // A route whose loader throws a plain Error — for asserting that the failure is
2
+ // reported with the route file's location (in dev) rather than anonymously.
3
+ export function loader() {
4
+ throw new Error("kaboom from loader");
5
+ }
6
+
7
+ export default function Boom() {
8
+ return <p>unreachable — loader throws</p>;
9
+ }
@@ -0,0 +1,16 @@
1
+ // Selective SSR: `ssr: false` — the loader must NOT run during document SSR
2
+ // and the Fallback must render in the component's place. The client completes
3
+ // the render via /_data after hydration.
4
+ export const ssr = false;
5
+
6
+ export function loader() {
7
+ return { secret: "CLIENT-ONLY-LOADER-DATA" };
8
+ }
9
+
10
+ export function Fallback() {
11
+ return <p>client-only fallback</p>;
12
+ }
13
+
14
+ export default function ClientOnlyPage() {
15
+ return <p>client-only component</p>;
16
+ }
@@ -0,0 +1,16 @@
1
+ // Module-level mutable state so tests can prove the submit → revalidate
2
+ // contract: a mutation changes what the loader returns on the next /_data.
3
+ let count = 0;
4
+
5
+ export function loader() {
6
+ return { count };
7
+ }
8
+
9
+ export async function action() {
10
+ count++;
11
+ return { ok: true, count };
12
+ }
13
+
14
+ export default function CounterPage() {
15
+ return <p>counter</p>;
16
+ }
@@ -0,0 +1,16 @@
1
+ // Selective SSR: `ssr: "data-only"` — loaders DO run during document SSR (the
2
+ // data ships in the bootstrap payload), but the component renders only on the
3
+ // client; the Fallback SSRs in its place.
4
+ export const ssr = "data-only";
5
+
6
+ export function loader() {
7
+ return { payload: "DATA-ONLY-LOADER-DATA" };
8
+ }
9
+
10
+ export function Fallback() {
11
+ return <p>data-only fallback</p>;
12
+ }
13
+
14
+ export default function DataOnlyPage() {
15
+ return <p>data-only component</p>;
16
+ }
@@ -0,0 +1,46 @@
1
+ // Exercises defineActions + <Form intent> + safeValidate end to end.
2
+ import { defineActions, safeValidate, formText } from "../../../../index.ts";
3
+ import { Form } from "../../../../client/components/Form.tsx";
4
+ import type { Schema } from "../../../../server/validate.ts";
5
+
6
+ const TitleSchema: Schema<{ title: string }> = {
7
+ safeParse(input: unknown) {
8
+ const t = typeof (input as { title?: unknown })?.title === "string"
9
+ ? ((input as { title: string }).title).trim()
10
+ : "";
11
+ return t
12
+ ? { success: true, data: { title: t } }
13
+ : { success: false, error: { issues: [{ path: ["title"], message: "Title required" }] } };
14
+ },
15
+ };
16
+
17
+ let count = 0;
18
+
19
+ export function loader() {
20
+ return { count };
21
+ }
22
+
23
+ export const action = defineActions({
24
+ add: async ({ formData }) => {
25
+ const r = await safeValidate(TitleSchema, formData);
26
+ if (!r.ok) return { error: r.firstError };
27
+ count++;
28
+ return { ok: true, title: r.data.title, count };
29
+ },
30
+ remove: ({ formData }) => {
31
+ formText(formData, "id"); // exercise the helper
32
+ if (count > 0) count--;
33
+ return { ok: true, count };
34
+ },
35
+ });
36
+
37
+ export default function IntentDemo() {
38
+ return (
39
+ <main>
40
+ <Form intent="add">
41
+ <input name="title" />
42
+ <button type="submit">Add</button>
43
+ </Form>
44
+ </main>
45
+ );
46
+ }
@@ -0,0 +1,15 @@
1
+ // Security invariant: `ssr: false` must NOT skip beforeLoad — it is the auth
2
+ // gate, and it runs for document GETs and /_data alike regardless of SSR mode.
3
+ export const ssr = false;
4
+
5
+ export function beforeLoad() {
6
+ return new Response("Forbidden", { status: 403 });
7
+ }
8
+
9
+ export function loader() {
10
+ return { secret: "GATED-CLIENT-ONLY-DATA" };
11
+ }
12
+
13
+ export default function ProtectedClientOnlyPage() {
14
+ return <p>protected client-only</p>;
15
+ }
@@ -0,0 +1,39 @@
1
+ import type { LoaderArgs } from "../../../../shared/route-types.ts";
2
+
3
+ // Hand-rolled Zod-compatible schema (the repo has no zod dependency): coerces
4
+ // `page` to a positive integer defaulting to 1; passes `tag` through as an
5
+ // array. Mirrors what `z.object({ page: z.coerce.number().int().positive()
6
+ // .default(1), tag: z.array(z.string()).optional() })` would do.
7
+ export const searchSchema = {
8
+ safeParse(input: unknown) {
9
+ const obj = (input ?? {}) as Record<string, unknown>;
10
+ const issues: Array<{ path: (string | number)[]; message: string }> = [];
11
+
12
+ let page = 1;
13
+ if (obj.page !== undefined) {
14
+ const n = Number(obj.page);
15
+ if (!Number.isInteger(n) || n < 1) {
16
+ issues.push({ path: ["page"], message: "page must be a positive integer" });
17
+ } else {
18
+ page = n;
19
+ }
20
+ }
21
+
22
+ if (issues.length > 0) return { success: false, error: { issues } };
23
+
24
+ const data: Record<string, unknown> = { page };
25
+ if (typeof obj.tag === "string") data.tag = [obj.tag];
26
+ else if (Array.isArray(obj.tag)) data.tag = obj.tag;
27
+ return { success: true, data };
28
+ },
29
+ };
30
+
31
+ // Echo the validated search object so tests can assert loaders receive the
32
+ // coerced shape, not raw strings.
33
+ export function loader({ search }: LoaderArgs) {
34
+ return { receivedSearch: search };
35
+ }
36
+
37
+ export default function SearchDemoPage() {
38
+ return <p>search demo</p>;
39
+ }
@@ -0,0 +1,43 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { formText, formValues } from "../shared/form-data.ts";
3
+
4
+ describe("formText", () => {
5
+ test("returns the string value", () => {
6
+ const fd = new FormData();
7
+ fd.set("id", "42");
8
+ expect(formText(fd, "id")).toBe("42");
9
+ });
10
+
11
+ test('returns "" for a missing key', () => {
12
+ expect(formText(new FormData(), "nope")).toBe("");
13
+ });
14
+
15
+ test('returns "" for a File value', () => {
16
+ const fd = new FormData();
17
+ fd.set("upload", new File(["x"], "a.txt"));
18
+ expect(formText(fd, "upload")).toBe("");
19
+ });
20
+ });
21
+
22
+ describe("formValues", () => {
23
+ test("collects all string entries (skips files)", () => {
24
+ const fd = new FormData();
25
+ fd.set("a", "1");
26
+ fd.set("b", "2");
27
+ fd.set("file", new File(["x"], "a.txt"));
28
+ expect(formValues(fd)).toEqual({ a: "1", b: "2" });
29
+ });
30
+
31
+ test("first occurrence wins for repeated keys", () => {
32
+ const fd = new FormData();
33
+ fd.append("tag", "one");
34
+ fd.append("tag", "two");
35
+ expect(formValues(fd)).toEqual({ tag: "one" });
36
+ });
37
+
38
+ test("named subset defaults missing keys to ''", () => {
39
+ const fd = new FormData();
40
+ fd.set("title", "Hello");
41
+ expect(formValues(fd, ["title", "body"])).toEqual({ title: "Hello", body: "" });
42
+ });
43
+ });
@@ -34,6 +34,16 @@ test("GET /_data?path=/ returns JSON with route key", async () => {
34
34
  expect(data).toHaveProperty("params");
35
35
  });
36
36
 
37
+ // Regression: soft navigation reads `meta` from the /_data payload to update
38
+ // the document head — when the payload omitted it, every soft-nav wiped the
39
+ // title/description back to nothing.
40
+ test("GET /_data?path=/ includes the route's merged meta", async () => {
41
+ const res = await fetch(`${BASE}/_data?path=/`);
42
+ const data = (await res.json()) as { meta?: Array<Record<string, string>> };
43
+ expect(Array.isArray(data.meta)).toBe(true);
44
+ expect(data.meta).toContainEqual({ title: "BractJS Test Home" });
45
+ });
46
+
37
47
  test("POST / runs action and returns 200 HTML", async () => {
38
48
  const form = new FormData();
39
49
  form.set("name", "bract");
@@ -72,6 +82,52 @@ test("GET /nonexistent returns 404", async () => {
72
82
  expect(res.status).toBe(404);
73
83
  });
74
84
 
85
+ // defineActions: dispatch on the form's `intent` field through one route action.
86
+ test("defineActions dispatches POST intent=add to the right handler", async () => {
87
+ const form = new FormData();
88
+ form.set("intent", "add");
89
+ form.set("title", "Buy milk");
90
+ const res = await fetch(`${BASE}/intent-demo`, {
91
+ method: "POST",
92
+ body: form,
93
+ headers: { Origin: BASE, "X-BractJS-Action": "1" },
94
+ });
95
+ expect(res.status).toBe(200);
96
+ const data = (await res.json()) as { ok?: boolean; title?: string };
97
+ expect(data.ok).toBe(true);
98
+ expect(data.title).toBe("Buy milk");
99
+ });
100
+
101
+ test("defineActions returns 400 for an unknown intent", async () => {
102
+ const form = new FormData();
103
+ form.set("intent", "bogus");
104
+ const res = await fetch(`${BASE}/intent-demo`, {
105
+ method: "POST",
106
+ body: form,
107
+ headers: { Origin: BASE, "X-BractJS-Action": "1" },
108
+ });
109
+ expect(res.status).toBe(400);
110
+ });
111
+
112
+ // <Form intent="add"> renders the hidden input server-side (no DOM harness:
113
+ // assert on the SSR HTML directly).
114
+ test("<Form intent> renders the hidden intent input in SSR HTML", async () => {
115
+ const res = await fetch(`${BASE}/intent-demo`);
116
+ const html = await res.text();
117
+ expect(html).toContain('name="intent"');
118
+ expect(html).toContain('value="add"');
119
+ });
120
+
121
+ // A loader that throws is isolated into the route's __error slot (not a 500),
122
+ // so layout/root still render. (The dev-only `routeFile` field is covered as a
123
+ // unit in loader.test.ts; this server runs in prod mode.)
124
+ test("a throwing loader is captured in the route slot's __error", async () => {
125
+ const res = await fetch(`${BASE}/_data?path=/boom`);
126
+ expect(res.status).toBe(200);
127
+ const data = (await res.json()) as { route: { __error?: { message: string } } };
128
+ expect(data.route.__error).toBeDefined();
129
+ });
130
+
75
131
  test("HTML includes window.__BRACTJS_DATA__", async () => {
76
132
  const res = await fetch(`${BASE}/`);
77
133
  const html = await res.text();
@@ -1,4 +1,4 @@
1
- import { test, expect, describe } from "bun:test";
1
+ import { test, expect, describe, spyOn } from "bun:test";
2
2
  import { safeRun, runLoaders, buildLoaderArgs } from "../server/loader.ts";
3
3
  import { HttpError } from "../shared/errors.ts";
4
4
  import type { LoaderArgs } from "../shared/route-types.ts";
@@ -9,6 +9,7 @@ const stubArgs: LoaderArgs = {
9
9
  request: new Request("http://localhost/"),
10
10
  params: {},
11
11
  context: {},
12
+ search: {},
12
13
  };
13
14
 
14
15
  const emptyModule: RouteModule = {};
@@ -41,6 +42,36 @@ describe("safeRun", () => {
41
42
  const fn = async () => { throw new Response(null, { status: 302, headers: { Location: "/" } }); };
42
43
  await expect(safeRun(fn, stubArgs)).rejects.toBeInstanceOf(Response);
43
44
  });
45
+
46
+ test("includes the `where` location in the error log", async () => {
47
+ const spy = spyOn(console, "error").mockImplementation(() => {});
48
+ try {
49
+ await safeRun(async () => { throw new Error("boom"); }, stubArgs, undefined, "routes/x.tsx");
50
+ const logged = spy.mock.calls.map((c) => String(c[0])).join("\n");
51
+ expect(logged).toContain("loader error in routes/x.tsx");
52
+ } finally {
53
+ spy.mockRestore();
54
+ }
55
+ });
56
+
57
+ test("dev __error carries the routeFile; prod stays generic", async () => {
58
+ const original = Bun.env.NODE_ENV;
59
+ const spy = spyOn(console, "error").mockImplementation(() => {});
60
+ try {
61
+ Bun.env.NODE_ENV = "development";
62
+ const dev = await safeRun(async () => { throw new Error("boom"); }, stubArgs, undefined, "routes/x.tsx");
63
+ expect(dev).toMatchObject({ __error: { routeFile: "routes/x.tsx" } });
64
+
65
+ Bun.env.NODE_ENV = "production";
66
+ const prod = await safeRun(async () => { throw new Error("boom"); }, stubArgs, undefined, "routes/x.tsx") as { __error: Record<string, unknown> };
67
+ expect(prod.__error.routeFile).toBeUndefined();
68
+ expect(prod.__error.message).toBe("Internal Server Error");
69
+ } finally {
70
+ if (original === undefined) delete Bun.env.NODE_ENV;
71
+ else Bun.env.NODE_ENV = original;
72
+ spy.mockRestore();
73
+ }
74
+ });
44
75
  });
45
76
 
46
77
  describe("runLoaders", () => {
@@ -0,0 +1,46 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { parseTo, createLocationKey } from "../client/nav-utils.ts";
3
+
4
+ describe("parseTo", () => {
5
+ test("plain pathname", () => {
6
+ expect(parseTo("/posts")).toEqual({ pathname: "/posts", search: "", hash: "" });
7
+ });
8
+
9
+ test("pathname + search", () => {
10
+ expect(parseTo("/posts?page=2")).toEqual({ pathname: "/posts", search: "?page=2", hash: "" });
11
+ });
12
+
13
+ test("pathname + hash", () => {
14
+ expect(parseTo("/docs#install")).toEqual({ pathname: "/docs", search: "", hash: "#install" });
15
+ });
16
+
17
+ test("pathname + search + hash", () => {
18
+ expect(parseTo("/docs?v=2#install")).toEqual({ pathname: "/docs", search: "?v=2", hash: "#install" });
19
+ });
20
+
21
+ test("hash containing a question mark stays in the hash", () => {
22
+ expect(parseTo("/docs#frag?notsearch")).toEqual({ pathname: "/docs", search: "", hash: "#frag?notsearch" });
23
+ });
24
+
25
+ test("empty string falls back to root", () => {
26
+ expect(parseTo("")).toEqual({ pathname: "/", search: "", hash: "" });
27
+ });
28
+
29
+ test("bare query string keeps root pathname", () => {
30
+ expect(parseTo("?page=2")).toEqual({ pathname: "/", search: "?page=2", hash: "" });
31
+ });
32
+
33
+ test("root with everything", () => {
34
+ expect(parseTo("/?a=1&b=2#top")).toEqual({ pathname: "/", search: "?a=1&b=2", hash: "#top" });
35
+ });
36
+ });
37
+
38
+ describe("createLocationKey", () => {
39
+ test("returns a short non-empty string and varies between calls", () => {
40
+ const a = createLocationKey();
41
+ const b = createLocationKey();
42
+ expect(a.length).toBeGreaterThanOrEqual(6);
43
+ expect(a.length).toBeLessThanOrEqual(10);
44
+ expect(a).not.toBe(b);
45
+ });
46
+ });