Skip to content

Commit 713232a

Browse files
committed
feat: implement AppContext and ServerSidePage for improved server-side rendering and state management
1 parent 0d0eef5 commit 713232a

File tree

6 files changed

+186
-82
lines changed

6 files changed

+186
-82
lines changed

packages/pranx/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,10 @@
7777
"h3": "2.0.0-beta.3",
7878
"kleur": "^4.1.5",
7979
"magic-string": "^0.30.18",
80+
"ofetch": "^1.4.1",
8081
"pathe": "^2.0.3",
8182
"preact-iso": "^2.10.0",
83+
"ufo": "^1.6.1",
8284
"unhead": "^2.0.14"
8385
},
8486
"peerDependencies": {
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { createContext } from "preact";
2+
import type { PropsWithChildren } from "preact/compat";
3+
import { useContext, useState } from "preact/hooks";
4+
5+
export type AppContext = {
6+
_params: Record<string, string> | null;
7+
_props: Record<string, any> | null;
8+
set(key: "params" | "props", data: any): void;
9+
};
10+
11+
const AppContextInstance = createContext<AppContext>({
12+
_params: {},
13+
_props: {},
14+
set() {},
15+
});
16+
17+
export const useAppContext = () => {
18+
const c = useContext(AppContextInstance);
19+
if (!c) {
20+
throw new Error("useAppContext must be used within a AppContextProvider");
21+
}
22+
return c;
23+
};
24+
25+
export const AppContextProvider = (props: PropsWithChildren) => {
26+
const [current_props, setCurrentProps] = useState<AppContext["_props"]>(null);
27+
const [current_params, setCurrentParams] = useState<AppContext["_params"]>(null);
28+
29+
return (
30+
<AppContextInstance.Provider
31+
value={{
32+
_params: current_params,
33+
_props: current_props,
34+
set(key, data) {
35+
if (key === "params") {
36+
setCurrentParams(data);
37+
} else {
38+
setCurrentProps(data);
39+
}
40+
},
41+
}}
42+
>
43+
{props.children}
44+
</AppContextInstance.Provider>
45+
);
46+
};
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { ofetch } from "ofetch";
2+
import { cloneElement, type VNode } from "preact";
3+
import { Children, type PropsWithChildren, useLayoutEffect } from "preact/compat";
4+
import type { HydrateDataRoute } from "types/index.js";
5+
import { useAppContext } from "./AppContext.js";
6+
7+
export const ServerSidePage = (props: PropsWithChildren & { route_data: HydrateDataRoute }) => {
8+
const child = Children.only(props.children);
9+
const { _props, set } = useAppContext();
10+
11+
// biome-ignore lint/correctness/useExhaustiveDependencies: <>
12+
useLayoutEffect(() => {
13+
const props_fetch_handler = async () => {
14+
const abortController = new AbortController();
15+
16+
try {
17+
const props_result = await ofetch<{ props: Record<string, any> }>(props.route_data.path, {
18+
method: "GET",
19+
query: {
20+
props: true,
21+
},
22+
signal: abortController.signal,
23+
});
24+
25+
setTimeout(() => {
26+
set("props", props_result.props);
27+
}, 2000);
28+
} catch (error) {
29+
if (!(error instanceof Error)) return;
30+
31+
if (error.name !== "AbortError") {
32+
console.error("Failed to fetch props:", error);
33+
}
34+
}
35+
36+
return () => {
37+
abortController.abort();
38+
};
39+
};
40+
41+
props_fetch_handler();
42+
}, []);
43+
44+
return cloneElement(child as VNode, _props || props.route_data.props);
45+
};
Lines changed: 70 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,97 +1,94 @@
1-
import type { VNode } from "preact";
21
import { ErrorBoundary, lazy, LocationProvider, Route, Router } from "preact-iso";
3-
import { Children, cloneElement, type PropsWithChildren } from "preact/compat";
42
import type { HydrateDataRoute } from "types/index.js";
53
import { useHead } from "unhead";
4+
import { AppContextProvider } from "./AppContext.js";
65
import { exec_route_match } from "./exec-match.js";
76
import { UNHEAD_INSTANCE } from "./head.js";
7+
import { ServerSidePage } from "./ServerSidePage.js";
88

99
let headUsed: ReturnType<typeof useHead> | null = null;
1010

1111
export function StartApp() {
1212
const HYDRATE_DATA = window.__PRANX_HYDRATE_DATA__;
1313

1414
return (
15-
<LocationProvider>
16-
<ErrorBoundary onError={console.error}>
17-
<Router
18-
onRouteChange={() => {
19-
let current_route: HydrateDataRoute | null = null;
15+
<AppContextProvider>
16+
<LocationProvider>
17+
<ErrorBoundary onError={console.error}>
18+
<Router
19+
onRouteChange={() => {
20+
let current_route: HydrateDataRoute | null = null;
2021

21-
for (const r of window.__PRANX_HYDRATE_DATA__.routes) {
22-
const exec_result = exec_route_match(
23-
window.location.pathname,
24-
r.path_parsed_for_routing
25-
);
22+
for (const r of window.__PRANX_HYDRATE_DATA__.routes) {
23+
const exec_result = exec_route_match(
24+
window.location.pathname,
25+
r.path_parsed_for_routing
26+
);
2627

27-
if (exec_result) {
28-
current_route = r;
29-
break;
28+
if (exec_result) {
29+
current_route = r;
30+
break;
31+
}
3032
}
31-
}
32-
33-
const head_css_config_links = current_route?.css.map((p) => {
34-
return {
35-
href: p,
36-
rel: "stylesheet",
37-
};
38-
});
3933

40-
if (headUsed !== null) {
41-
headUsed.patch({
42-
link: head_css_config_links,
43-
});
44-
} else {
45-
headUsed = useHead(UNHEAD_INSTANCE, {
46-
link: head_css_config_links,
34+
const head_css_config_links = current_route?.css.map((p) => {
35+
return {
36+
href: p,
37+
rel: "stylesheet",
38+
};
4739
});
48-
}
49-
}}
50-
>
51-
{HYDRATE_DATA.routes.map((r) => {
52-
const Page = lazy(() => import(r.module));
5340

54-
return (
55-
<Route
56-
key={r.path}
57-
path={r.path_parsed_for_routing}
58-
component={() => {
59-
let props = r.props;
41+
if (headUsed !== null) {
42+
headUsed.patch({
43+
link: head_css_config_links,
44+
});
45+
} else {
46+
headUsed = useHead(UNHEAD_INSTANCE, {
47+
link: head_css_config_links,
48+
});
49+
}
50+
}}
51+
>
52+
{HYDRATE_DATA.routes.map((r) => {
53+
const Page = lazy(() => import(r.module));
6054

61-
if (!r.is_dynamic) {
62-
return (
63-
<ServerSidePage
64-
// biome-ignore lint/correctness/noChildrenProp: <>
65-
children={<Page />}
66-
route_data={r}
67-
/>
68-
);
69-
}
55+
return (
56+
<Route
57+
key={r.path}
58+
path={r.path_parsed_for_routing}
59+
component={() => {
60+
let props = r.props;
7061

71-
for (const route of r.static_generated_routes) {
72-
if (route.path === window.location.pathname) {
73-
props = route.props;
74-
break;
62+
if (r.rendering_kind === "server-side") {
63+
return (
64+
<ServerSidePage
65+
// biome-ignore lint/correctness/noChildrenProp: <>
66+
children={<Page />}
67+
route_data={r}
68+
/>
69+
);
7570
}
76-
}
7771

78-
return <Page {...props} />;
79-
}}
80-
/>
81-
);
82-
})}
83-
<Route
84-
default
85-
component={() => <h1>Not found</h1>}
86-
/>
87-
</Router>
88-
</ErrorBoundary>
89-
</LocationProvider>
90-
);
91-
}
72+
for (const route of r.static_generated_routes) {
73+
if (route.path === window.location.pathname) {
74+
props = route.props;
75+
break;
76+
}
77+
}
9278

93-
const ServerSidePage = (props: PropsWithChildren & { route_data: HydrateDataRoute }) => {
94-
const child = Children.only(props.children);
79+
return <Page {...props} />;
80+
}}
81+
/>
82+
);
83+
})}
9584

96-
return cloneElement(child as VNode, props.route_data.props);
97-
};
85+
<Route
86+
default
87+
component={() => <h1>Not found</h1>}
88+
/>
89+
</Router>
90+
</ErrorBoundary>
91+
</LocationProvider>
92+
</AppContextProvider>
93+
);
94+
}

packages/pranx/src/cmd/start.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { join, resolve } from "pathe";
1717
import { Fragment, h } from "preact";
1818
import { renderToStringAsync } from "preact-render-to-string";
1919
import type { HYDRATE_DATA, PageModule, SERVER_MANIFEST, ServerEntryModule } from "types/index.js";
20+
import * as ufo from "ufo";
2021

2122
export async function start() {
2223
measureTime("pranx-start");
@@ -31,32 +32,39 @@ export async function start() {
3132

3233
for (const route of server_manifest.routes) {
3334
if (route.rendering_kind === "server-side") {
35+
let server_entry_module: ServerEntryModule | null = null;
36+
server_entry_module = (await import(server_manifest.entry_server)) as ServerEntryModule;
37+
38+
const file_absolute = resolve(join(OUTPUT_BUNDLE_SERVER_DIR, "pages", route.module));
39+
const { default: page, getServerSideProps } = (await import(file_absolute)) as PageModule;
40+
3441
app.on(
3542
"GET",
3643
filePathToRoutingPath(route.path, false),
3744
defineHandler({
3845
middleware: [],
3946
meta: {},
4047
handler: async (event) => {
41-
let server_entry_module: ServerEntryModule | null = null;
42-
43-
server_entry_module = (await import(server_manifest.entry_server)) as ServerEntryModule;
44-
45-
const file_absolute = resolve(join(OUTPUT_BUNDLE_SERVER_DIR, "pages", route.module));
46-
47-
const { default: page, getServerSideProps } = (await import(
48-
file_absolute
49-
)) as PageModule;
48+
const url_parsed = ufo.parseURL(event.req.url);
49+
const params = new URLSearchParams(url_parsed.search);
50+
const return_only_props = Boolean(params.get("props"));
5051

5152
let props = {};
5253

5354
if (getServerSideProps) {
5455
props = await getServerSideProps();
5556
}
5657

58+
if (return_only_props) {
59+
return {
60+
props,
61+
};
62+
}
63+
5764
const hydrate_data = (await fse.readJSON(SITE_MANIFEST_OUTPUT_PATH)) as HYDRATE_DATA;
5865

5966
const target_route = hydrate_data.routes.find((r) => r.path === route.path);
67+
6068
if (!target_route) {
6169
logger.error(`Route not found in hydrate data: ${route.path}`);
6270
event.res.status = 500;

pnpm-lock.yaml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)