Skip to content

Commit 88bbe1f

Browse files
committed
New loader arch
1 parent 0692312 commit 88bbe1f

9 files changed

Lines changed: 323 additions & 8 deletions

File tree

VISION_MVP.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,30 @@ ISG supports time-based and webhook-based revalidation policies.
4949

5050
### Data Loading
5151

52+
Two styles, both fully supported — pick whichever fits your mental model:
53+
54+
**Inline (co-located in the route file):**
5255
- **Loaders**: `export function loader(args)` — runs at build (SSG), request (SSR),
5356
or client navigation time. Returns typed, serializable data.
5457
- **Actions**: `export function action(args)` — handles POST/PUT/PATCH/DELETE.
5558
Returns data, redirects, or revalidation hints.
59+
60+
**Separate files (manifest-wired):**
61+
- Loader and action live in dedicated server files (e.g. `src/server/`).
62+
- Wired via the route config object in `routes.ts`:
63+
```typescript
64+
route("/dashboard", {
65+
component: "./routes/dashboard.tsx",
66+
loader: "./server/dashboard-loader.ts",
67+
action: "./server/dashboard-action.ts",
68+
render: "ssr",
69+
})
70+
```
71+
- Route files become pure components — no server code mixed in.
72+
73+
Both styles can coexist in the same app. When a separate `loader`/`action` file
74+
is specified in the config, it takes precedence over inline exports.
75+
5676
- **Head**: `export function head(args)` — per-route `<head>` metadata merged with
5777
shell-level head.
5878
- **Client hooks**: `useRouteData()`, `useRevalidateRoute()`, `useSubmitAction()`,

packages/framework/src/app.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
ResolvedApiRoute,
66
ResolvedRoute,
77
ResolvedViactApp,
8+
RouteConfig,
89
RouteDefinition,
910
RouteMatch,
1011
RouteMeta,
@@ -34,12 +35,30 @@ export function timeRevalidate(seconds: number): TimeRevalidatePolicy {
3435
};
3536
}
3637

37-
export function route(path: string, file: string, meta: RouteMeta = {}): RouteDefinition {
38+
export function route(path: string, file: string, meta?: RouteMeta): RouteDefinition;
39+
export function route(path: string, config: RouteConfig): RouteDefinition;
40+
export function route(
41+
path: string,
42+
fileOrConfig: string | RouteConfig,
43+
meta: RouteMeta = {},
44+
): RouteDefinition {
45+
if (typeof fileOrConfig === "string") {
46+
return {
47+
kind: "route",
48+
path: normalizeRoutePath(path),
49+
file: fileOrConfig,
50+
...meta,
51+
};
52+
}
53+
54+
const { component, loader, action, ...routeMeta } = fileOrConfig;
3855
return {
3956
kind: "route",
4057
path: normalizeRoutePath(path),
41-
file,
42-
...meta,
58+
file: component,
59+
loaderFile: loader,
60+
actionFile: action,
61+
...routeMeta,
4362
};
4463
}
4564

@@ -131,6 +150,8 @@ function flattenRouteNode(
131150
id: node.id ?? createRouteId(fullPath),
132151
path: fullPath,
133152
file: node.file,
153+
loaderFile: node.loaderFile,
154+
actionFile: node.actionFile,
134155
shell,
135156
shellFile: shell ? app.shells[shell] : undefined,
136157
render: node.render ?? inherited.render,

packages/framework/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export type {
3434
ApiRouteMatch,
3535
ApiRouteModule,
3636
BaseRouteArgs,
37+
DataModule,
3738
ErrorBoundaryProps,
3839
GroupDefinition,
3940
GroupMeta,
@@ -55,6 +56,7 @@ export type {
5556
ResolvedRoute,
5657
ResolvedViactApp,
5758
RouteComponentProps,
59+
RouteConfig,
5860
RouteDefinition,
5961
RouteMatch,
6062
RouteMeta,

packages/framework/src/router.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createContext, h } from "preact";
1+
import { createContext, h, options } from "preact";
22
import { hydrate, render } from "preact";
33
import { useContext } from "preact/hooks";
44
import type { VNode } from "preact";
@@ -149,6 +149,22 @@ export async function initClientRouter(options: InitClientRouterOptions): Promis
149149
}
150150
}
151151

152+
// ------------------------------------------------------------------
153+
// Dev-mode hydration mismatch monitor (Preact options.__m hook)
154+
// ------------------------------------------------------------------
155+
156+
if ((import.meta as any).env?.DEV) {
157+
const prev = (options as any).__m;
158+
(options as any).__m = (vnode: VNode, s: string) => {
159+
const component =
160+
typeof vnode.type === "function" ? vnode.type.displayName || vnode.type.name : vnode.type;
161+
const message = `Hydration mismatch in <${component || "Unknown"}>: ${s}`;
162+
console.warn(`[viact] ${message}`);
163+
appendHydrationWarning(message);
164+
if (prev) prev(vnode, s);
165+
};
166+
}
167+
152168
// ------------------------------------------------------------------
153169
// Initial hydration — includes NavigateContext so useNavigate works
154170
// ------------------------------------------------------------------
@@ -250,6 +266,65 @@ export async function initClientRouter(options: InitClientRouterOptions): Promis
250266
setupPrefetching(app);
251267
}
252268

269+
// ---------------------------------------------------------------------------
270+
// Dev-only: in-page hydration mismatch warning banner
271+
// ---------------------------------------------------------------------------
272+
273+
const HYDRATION_BANNER_ID = "__viact_hydration_warnings__";
274+
275+
function appendHydrationWarning(message: string): void {
276+
let container = document.getElementById(HYDRATION_BANNER_ID);
277+
if (!container) {
278+
container = document.createElement("div");
279+
container.id = HYDRATION_BANNER_ID;
280+
Object.assign(container.style, {
281+
position: "fixed",
282+
bottom: "0",
283+
left: "0",
284+
right: "0",
285+
maxHeight: "30vh",
286+
overflow: "auto",
287+
background: "#2d1b00",
288+
borderTop: "2px solid #f0ad4e",
289+
color: "#ffc107",
290+
fontFamily: "ui-monospace, Consolas, monospace",
291+
fontSize: "13px",
292+
padding: "12px 16px",
293+
zIndex: "2147483647",
294+
});
295+
296+
const header = document.createElement("div");
297+
Object.assign(header.style, {
298+
display: "flex",
299+
justifyContent: "space-between",
300+
alignItems: "center",
301+
marginBottom: "8px",
302+
});
303+
header.innerHTML =
304+
'<strong style="color:#f0ad4e">⚠ Hydration Mismatches</strong>';
305+
306+
const close = document.createElement("button");
307+
close.textContent = "×";
308+
Object.assign(close.style, {
309+
background: "none",
310+
border: "none",
311+
color: "#f0ad4e",
312+
fontSize: "18px",
313+
cursor: "pointer",
314+
});
315+
close.onclick = () => container!.remove();
316+
header.appendChild(close);
317+
318+
container.appendChild(header);
319+
document.body.appendChild(container);
320+
}
321+
322+
const entry = document.createElement("div");
323+
entry.textContent = message;
324+
Object.assign(entry.style, { padding: "2px 0" });
325+
container.appendChild(entry);
326+
}
327+
253328
function deserializeRouteError(error: SerializedRouteError): Error {
254329
const result = new Error(error.message);
255330
result.name = error.name;

packages/framework/src/runtime.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@ import type {
77
ApiRouteModule,
88
ActionEnvelope,
99
BaseRouteArgs,
10+
DataModule,
1011
HeadMetadata,
1112
HttpMethod,
1213
MiddlewareModule,
1314
ModuleImporter,
1415
ModuleRegistry,
1516
ResolvedApiRoute,
17+
ResolvedRoute,
1618
RouteModule,
1719
ShellModule,
1820
ViactHttpError,
@@ -419,25 +421,32 @@ export async function handleViactRequest<TContext>(
419421
route: match.route,
420422
};
421423

424+
// --- Resolve loader/action from separate data modules or route module ---
425+
const { loader, action } = await resolveDataFunctions(
426+
match.route,
427+
routeModule,
428+
registry,
429+
);
430+
422431
// --- Handle actions (POST/PUT/PATCH/DELETE) ---
423432
if (isAction) {
424-
if (!routeModule?.action) {
433+
if (!action) {
425434
return withDefaultSecurityHeaders(
426435
new Response("Method not allowed", {
427436
status: 405,
428437
headers: { "content-type": "text/plain; charset=utf-8" },
429438
}),
430439
);
431440
}
432-
const actionResult = await routeModule.action(routeArgs);
441+
const actionResult = await action(routeArgs);
433442
return actionResultToResponse(actionResult);
434443
}
435444

436445
let shellModule: ShellModule | undefined;
437446

438447
try {
439448
// --- Execute loader ---
440-
const loaderResult = routeModule?.loader ? await routeModule.loader(routeArgs) : undefined;
449+
const loaderResult = loader ? await loader(routeArgs) : undefined;
441450

442451
// Allow loaders to return a Response directly (e.g. for redirects)
443452
if (loaderResult instanceof Response) {
@@ -862,6 +871,33 @@ async function runMiddlewareChain<TContext>(options: {
862871
return { context };
863872
}
864873

874+
async function resolveDataFunctions(
875+
route: ResolvedRoute,
876+
routeModule: RouteModule | undefined,
877+
registry: ModuleRegistry,
878+
): Promise<{ loader: RouteModule["loader"]; action: RouteModule["action"] }> {
879+
let loader = routeModule?.loader;
880+
let action = routeModule?.action;
881+
882+
if (route.loaderFile) {
883+
const dataModule = await resolveRegistryModule<DataModule>(
884+
registry.dataModules,
885+
route.loaderFile,
886+
);
887+
if (dataModule?.loader) loader = dataModule.loader;
888+
}
889+
890+
if (route.actionFile) {
891+
const dataModule = await resolveRegistryModule<DataModule>(
892+
registry.dataModules,
893+
route.actionFile,
894+
);
895+
if (dataModule?.action) action = dataModule.action;
896+
}
897+
898+
return { loader, action };
899+
}
900+
865901
async function resolveRegistryModule<T>(
866902
modules: Record<string, ModuleImporter> | undefined,
867903
file: string,
@@ -895,6 +931,7 @@ async function mergeHeadMetadata(
895931

896932
return {
897933
title: routeHead.title ?? shellHead.title,
934+
lang: routeHead.lang ?? shellHead.lang,
898935
meta: [...(shellHead.meta ?? []), ...(routeHead.meta ?? [])],
899936
link: [...(shellHead.link ?? []), ...(routeHead.link ?? [])],
900937
};
@@ -944,7 +981,7 @@ function buildHtmlDocument(options: {
944981
: "";
945982

946983
return `<!DOCTYPE html>
947-
<html>
984+
<html${head.lang ? ` lang="${escapeHtml(head.lang)}"` : ""}>
948985
<head>
949986
<meta charset="utf-8">
950987
${titleTag}

packages/framework/src/types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,18 @@ export interface ApiConfig {
6161
middleware?: string[];
6262
}
6363

64+
export interface RouteConfig extends RouteMeta {
65+
component: string;
66+
loader?: string;
67+
action?: string;
68+
}
69+
6470
export interface RouteDefinition extends RouteMeta {
6571
kind: "route";
6672
path: string;
6773
file: string;
74+
loaderFile?: string;
75+
actionFile?: string;
6876
}
6977

7078
export interface GroupDefinition {
@@ -109,6 +117,8 @@ export type RouteSegment = StaticRouteSegment | ParamRouteSegment | CatchAllRout
109117
export interface ResolvedRoute extends Omit<RouteMeta, "middleware"> {
110118
path: string;
111119
file: string;
120+
loaderFile?: string;
121+
actionFile?: string;
112122
shell?: string;
113123
shellFile?: string;
114124
middleware: string[];
@@ -144,6 +154,7 @@ export interface MiddlewareArgs<TContext = unknown> extends BaseRouteArgs<TConte
144154

145155
export interface HeadMetadata {
146156
title?: string;
157+
lang?: string;
147158
meta?: Array<Record<string, string>>;
148159
link?: Array<Record<string, string>>;
149160
}
@@ -226,11 +237,17 @@ export interface MiddlewareModule<TContext = unknown> {
226237

227238
export type ModuleImporter<TModule = unknown> = () => Promise<TModule>;
228239

240+
export interface DataModule<TContext = unknown> {
241+
loader?: LoaderFn<TContext>;
242+
action?: ActionFn<TContext>;
243+
}
244+
229245
export interface ModuleRegistry {
230246
routeModules?: Record<string, ModuleImporter<RouteModule>>;
231247
shellModules?: Record<string, ModuleImporter<ShellModule>>;
232248
middlewareModules?: Record<string, ModuleImporter<MiddlewareModule>>;
233249
apiModules?: Record<string, ModuleImporter<ApiRouteModule>>;
250+
dataModules?: Record<string, ModuleImporter<DataModule>>;
234251
}
235252

236253
export class ViactHttpError extends Error {

0 commit comments

Comments
 (0)