@bractjs/bractjs 0.1.26 → 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 (98) hide show
  1. package/README.md +283 -58
  2. package/bin/cli.ts +18 -1
  3. package/package.json +1 -1
  4. package/src/__tests__/build-path.test.ts +29 -0
  5. package/src/__tests__/codegen-write.test.ts +67 -0
  6. package/src/__tests__/codegen.test.ts +64 -1
  7. package/src/__tests__/compile-safety.test.ts +4 -0
  8. package/src/__tests__/csp.test.ts +10 -0
  9. package/src/__tests__/define-actions.test.ts +69 -0
  10. package/src/__tests__/env.test.ts +18 -0
  11. package/src/__tests__/fetcher-store.test.ts +67 -0
  12. package/src/__tests__/fixtures/app/root.tsx +7 -2
  13. package/src/__tests__/fixtures/app/routes/boom.tsx +9 -0
  14. package/src/__tests__/fixtures/app/routes/client-only.tsx +16 -0
  15. package/src/__tests__/fixtures/app/routes/counter.tsx +16 -0
  16. package/src/__tests__/fixtures/app/routes/data-only.tsx +16 -0
  17. package/src/__tests__/fixtures/app/routes/intent-demo.tsx +46 -0
  18. package/src/__tests__/fixtures/app/routes/protected-client-only.tsx +15 -0
  19. package/src/__tests__/fixtures/app/routes/search-demo.tsx +39 -0
  20. package/src/__tests__/form-data-helpers.test.ts +43 -0
  21. package/src/__tests__/integration.test.ts +56 -0
  22. package/src/__tests__/loader.test.ts +32 -1
  23. package/src/__tests__/nav-utils.test.ts +46 -0
  24. package/src/__tests__/prerender.test.ts +102 -0
  25. package/src/__tests__/programmatic-api.test.ts +20 -1
  26. package/src/__tests__/revalidation.test.ts +65 -0
  27. package/src/__tests__/route-lint.test.ts +74 -0
  28. package/src/__tests__/route-table.test.ts +33 -0
  29. package/src/__tests__/safe-validate.test.ts +96 -0
  30. package/src/__tests__/scroll-restoration.test.ts +66 -0
  31. package/src/__tests__/search-serializer.test.ts +42 -0
  32. package/src/__tests__/search-validation.test.ts +125 -0
  33. package/src/__tests__/security.test.ts +110 -1
  34. package/src/__tests__/selective-ssr.test.ts +85 -0
  35. package/src/__tests__/spa-mode.test.ts +77 -0
  36. package/src/__tests__/typed-routing.test.ts +239 -0
  37. package/src/build/bundler.ts +33 -0
  38. package/src/build/prerender.ts +88 -0
  39. package/src/build/route-lint.ts +49 -0
  40. package/src/client/ClientRouter.tsx +239 -47
  41. package/src/client/build-path.ts +24 -0
  42. package/src/client/cache.ts +8 -0
  43. package/src/client/components/Await.tsx +9 -2
  44. package/src/client/components/Form.tsx +23 -34
  45. package/src/client/components/Link.tsx +105 -11
  46. package/src/client/components/Outlet.tsx +8 -2
  47. package/src/client/components/ScrollRestoration.tsx +125 -0
  48. package/src/client/entry.tsx +39 -2
  49. package/src/client/fetcher-store.ts +61 -0
  50. package/src/client/form-utils.ts +3 -0
  51. package/src/client/hooks/useActionData.ts +7 -3
  52. package/src/client/hooks/useFetcher.ts +116 -33
  53. package/src/client/hooks/useFetchers.ts +23 -0
  54. package/src/client/hooks/useLoaderData.ts +8 -4
  55. package/src/client/hooks/useLocation.ts +27 -0
  56. package/src/client/hooks/useNavigate.ts +51 -0
  57. package/src/client/hooks/useParams.ts +15 -4
  58. package/src/client/hooks/useRevalidator.ts +26 -0
  59. package/src/client/hooks/useSearch.ts +73 -0
  60. package/src/client/hooks/useSearchParams.ts +21 -6
  61. package/src/client/nav-utils.ts +26 -0
  62. package/src/client/prefetch.ts +110 -15
  63. package/src/client/registry.ts +131 -0
  64. package/src/client/revalidation.ts +25 -0
  65. package/src/client/router.tsx +28 -1
  66. package/src/client/scroll-restoration.ts +48 -0
  67. package/src/client/search-serializer.ts +40 -0
  68. package/src/client/types.ts +6 -0
  69. package/src/codegen/route-codegen.ts +201 -29
  70. package/src/config/load.ts +21 -0
  71. package/src/dev/hmr-client.ts +3 -1
  72. package/src/dev/route-table.ts +27 -0
  73. package/src/dev/server.ts +106 -8
  74. package/src/dev/watcher.ts +25 -3
  75. package/src/index.ts +44 -3
  76. package/src/server/action-handler.ts +12 -3
  77. package/src/server/action-registry.ts +35 -0
  78. package/src/server/csp.ts +10 -1
  79. package/src/server/csrf.ts +26 -0
  80. package/src/server/env.ts +26 -5
  81. package/src/server/layout.ts +31 -1
  82. package/src/server/loader.ts +14 -8
  83. package/src/server/render.ts +18 -3
  84. package/src/server/request-handler.ts +50 -8
  85. package/src/server/search.ts +43 -0
  86. package/src/server/serve.ts +88 -1
  87. package/src/server/spa.ts +62 -0
  88. package/src/server/stream-handler.ts +10 -1
  89. package/src/server/validate.ts +85 -13
  90. package/src/shared/context.ts +5 -0
  91. package/src/shared/define-actions.ts +39 -0
  92. package/src/shared/form-data.ts +34 -0
  93. package/src/shared/route-types.ts +83 -2
  94. package/templates/new-app/app/root.tsx +2 -1
  95. package/templates/new-app/bractjs.config.ts +7 -12
  96. package/types/config.d.ts +21 -0
  97. package/types/index.d.ts +210 -10
  98. package/types/route.d.ts +62 -2
@@ -0,0 +1,67 @@
1
+ import { test, expect, describe, beforeEach, afterEach } from "bun:test";
2
+ import { mkdir, rm, writeFile, stat } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import {
6
+ writeRouteTypes,
7
+ explainStalenessForApp,
8
+ } from "../codegen/route-codegen.ts";
9
+
10
+ let appDir = "";
11
+
12
+ beforeEach(async () => {
13
+ appDir = join(tmpdir(), `bract-codegen-write-${Date.now()}-${Math.random().toString(36).slice(2)}`);
14
+ await mkdir(join(appDir, "routes"), { recursive: true });
15
+ await writeFile(join(appDir, "routes", "_index.tsx"), "export default () => null;");
16
+ });
17
+
18
+ afterEach(async () => {
19
+ await rm(appDir, { recursive: true, force: true });
20
+ });
21
+
22
+ describe("writeRouteTypes — idempotency", () => {
23
+ test("writes on first run, skips identical re-run, rewrites on route change", async () => {
24
+ const first = await writeRouteTypes(appDir);
25
+ expect(first.written).toBe(true);
26
+
27
+ const destStat = await stat(first.dest);
28
+ const mtime1 = destStat.mtimeMs;
29
+
30
+ // Identical re-run: no write (so no file-watcher event / editor reload loop).
31
+ const second = await writeRouteTypes(appDir);
32
+ expect(second.written).toBe(false);
33
+ expect((await stat(first.dest)).mtimeMs).toBe(mtime1);
34
+
35
+ // Add a route → content changes → write happens.
36
+ await writeFile(join(appDir, "routes", "about.tsx"), "export default () => null;");
37
+ const third = await writeRouteTypes(appDir);
38
+ expect(third.written).toBe(true);
39
+ });
40
+ });
41
+
42
+ describe("explainStalenessForApp", () => {
43
+ test("missing generated file → reason mentions missing", async () => {
44
+ const reason = await explainStalenessForApp(appDir);
45
+ expect(reason).toMatch(/missing/);
46
+ });
47
+
48
+ test("fresh after codegen → null", async () => {
49
+ await writeRouteTypes(appDir);
50
+ expect(await explainStalenessForApp(appDir)).toBeNull();
51
+ });
52
+
53
+ test("added route → reports +1", async () => {
54
+ await writeRouteTypes(appDir);
55
+ await writeFile(join(appDir, "routes", "about.tsx"), "export default () => null;");
56
+ const reason = await explainStalenessForApp(appDir);
57
+ expect(reason).toMatch(/\+1 added/);
58
+ });
59
+
60
+ test("removed route → reports -1", async () => {
61
+ await writeFile(join(appDir, "routes", "about.tsx"), "export default () => null;");
62
+ await writeRouteTypes(appDir);
63
+ await rm(join(appDir, "routes", "about.tsx"));
64
+ const reason = await explainStalenessForApp(appDir);
65
+ expect(reason).toMatch(/-1 removed/);
66
+ });
67
+ });
@@ -2,7 +2,11 @@ import { test, expect, describe, beforeAll, afterAll } from "bun:test";
2
2
  import { mkdir, rm, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
- import { generateRouteTypes } from "../codegen/route-codegen.ts";
5
+ import {
6
+ generateRouteTypes,
7
+ routesFingerprint,
8
+ readFingerprint,
9
+ } from "../codegen/route-codegen.ts";
6
10
 
7
11
  let appDir = "";
8
12
 
@@ -29,6 +33,65 @@ describe("route-codegen — output shape", () => {
29
33
  expect(out).toMatch(/\| "\/users\/:id"/);
30
34
  });
31
35
 
36
+ test("emits a fingerprint matching routesFingerprint, and is deterministic", async () => {
37
+ const out = await generateRouteTypes(appDir);
38
+ // Header carries the route fingerprint.
39
+ const embedded = readFingerprint(out);
40
+ expect(embedded).toMatch(/^[0-9a-f]+$/);
41
+ expect(embedded).toBe(await routesFingerprint(["/", "/users/:id"]));
42
+ // Same input → byte-identical output (order-independent / reproducible).
43
+ expect(await generateRouteTypes(appDir)).toBe(out);
44
+ // Pattern union is sorted (deterministic across filesystems).
45
+ expect(out.indexOf('| "/"')).toBeLessThan(out.indexOf('| "/users/:id"'));
46
+ });
47
+
48
+ test("routesFingerprint is order-independent", async () => {
49
+ expect(await routesFingerprint(["/a", "/b"])).toBe(await routesFingerprint(["/b", "/a"]));
50
+ expect(await routesFingerprint(["/a"])).not.toBe(await routesFingerprint(["/a", "/b"]));
51
+ });
52
+
53
+ test("wires the Register augmentation for typed routing", async () => {
54
+ const regApp = join(tmpdir(), `bract-codegen-register-${Date.now()}`);
55
+ await mkdir(join(regApp, "routes", "users"), { recursive: true });
56
+ await writeFile(join(regApp, "routes", "about.tsx"), "export default () => null;");
57
+ await writeFile(join(regApp, "routes", "users", "[id].tsx"), "export default () => null;");
58
+
59
+ const out = await generateRouteTypes(regApp);
60
+
61
+ // Bug 1 regression: the augmentation must target the real package name.
62
+ expect(out).toContain('declare module "@bractjs/bractjs"');
63
+ expect(out).not.toMatch(/declare module ['"]bractjs['"]/);
64
+
65
+ // Bug 2 regression: the customization maps are AUGMENTED on the package, not
66
+ // re-declared as bare top-level interfaces in the app file.
67
+ expect(out).not.toMatch(/^export interface RouteSearchParamsMap/m);
68
+ expect(out).not.toMatch(/^export interface RouteContextMap/m);
69
+ expect(out).toContain('import type { RouteSearchParamsMap, RouteContextMap, InferSchemaOutput } from "@bractjs/bractjs"');
70
+
71
+ // The Register seam carries the route union and a per-route params map.
72
+ expect(out).toContain("interface Register {");
73
+ expect(out).toContain("routes: AppRoutes;");
74
+ expect(out).toMatch(/"\/users\/:id": \{ id: string \};/); // dynamic route → typed params
75
+ expect(out).toMatch(/"\/about": \{\};/); // static route → no params
76
+
77
+ // Schema-inferred search shapes: a per-route map derived from each route
78
+ // module's `searchSchema` export, registered under `searchOutput`.
79
+ expect(out).toContain("export type GeneratedSearchOutput = {");
80
+ expect(out).toContain('typeof import("./routes/about.tsx") extends { searchSchema: infer S }');
81
+ expect(out).toContain("searchOutput: GeneratedSearchOutput;");
82
+
83
+ await rm(regApp, { recursive: true, force: true });
84
+ });
85
+
86
+ test("emits no Register augmentation when there are no routes", async () => {
87
+ const emptyApp = join(tmpdir(), `bract-codegen-empty-${Date.now()}`);
88
+ await mkdir(join(emptyApp, "routes"), { recursive: true });
89
+ const out = await generateRouteTypes(emptyApp);
90
+ expect(out).toContain("export type AppRoutes =\n never;");
91
+ expect(out).not.toContain("interface Register {");
92
+ await rm(emptyApp, { recursive: true, force: true });
93
+ });
94
+
32
95
  test("rejects hostile filenames at codegen time", async () => {
33
96
  const hostileApp = join(tmpdir(), `bract-codegen-hostile-${Date.now()}`);
34
97
  await mkdir(join(hostileApp, "routes"), { recursive: true });
@@ -51,6 +51,10 @@ const ALLOWED: Record<string, string[]> = {
51
51
  // calls it when no `registry` is provided; registry mode uses pickRouteModule
52
52
  // (a plain Record lookup, no import).
53
53
  "layout.ts": ["await import(filePath)"],
54
+ // renderSpaShell(): source-mode root.tsx load for the SPA shell. Compiled
55
+ // binaries always pass a moduleRegistry, which takes the registry branch
56
+ // (plain Record lookup) before this import is reached.
57
+ "spa.ts": ["await import(rootPath)"],
54
58
  };
55
59
 
56
60
  async function serverFiles(): Promise<string[]> {
@@ -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();