Description
The road to Fresh 2.0
tl;dr: Fresh will become massively simpler and be distributed via JSR with the upcoming 2.0 release. Express/Hono-like API and true async components and much more.
Back to Fresh!
Since yesterday, I've been going back to working on Fresh again. During the past few months I helped with shipping https://jsr.io/ which meant that Fresh sat on the back burner for me. Admittedly, I needed a bit a break from Fresh as well, and JSR came along at the right time.
Working on JSR allowed me to put myself into the user's seat and get first hands experiences with Fresh as a user, which was nice change! It also uncovered a lot of things where I feel we can make Fresh much better.
With JSR beeing out of the door, the next task for me is to move Fresh over from deno.land/x
to JSR. Given that this is a bit of a breaking change, @lucacasonato and I were wondering what a potential Fresh 2.0 release could look like.
We've never done a breaking Fresh release before, but now, after having worked for nearly a year on Fresh, the timing feels right. It allows us to revisit all the ideas in Fresh and part ways with those that didn't pan out.
We spent yesterday morning going through the most common problems people shared on Discord and in our issue tracker to get a good picture of where Fresh is at. The overall theme we want to have for Fresh 2, is to be massively simpler than before.
A new plugin API
It became pretty clear that many issues relate to the "clunkyness" of the current plugin API. It's the most common way to extend Fresh itself with capabilities that it doesn't include out of the box. Whilst it received many cool new features over time, it quite never felt elegant as it should be. This is no surprise given that nothing in Fresh itself makes use of it.
Back in July of last year there was some explorations on making Fresh's API more express/Hono-like #1487 and yesterday we realized that this is the perfect solution for nearly all the problems encountered with the current plugin API. Here is what we're picturing it to look like:
const app = new FreshApp();
// Custom middlewares
app.use(ctx => {
console.log(`Here is a cool request: ${ctx.url}`)
return ctx.next();
});
// Also can be branched on by HTTP method
app.get(ctx => {
return new Response("it works!")
});
// Adding an island (exact function signature yet to be determined)
app.addIsland(...)
// Finally, start the app
await app.listen();
A Fresh plugin would change from the complex object it is today, to being nothing more than a standard JavaScript function:
function disallowFoo(app: App) {
let counter = 0;
// Redirect to / whenever a route contains
// the word "foo".
app.use((ctx) => {
if (ctx.url.pathname.includes("foo")) {
counter++;
return ctx.redirect("/");
}
return ctx.next();
});
// Add a route that shows how many times we
// redirected folks
app.get("/counter", () => {
const msg = `Foo redirect counter: ${counter}`;
return new Response(msg);
});
}
// Usage
const app = new FreshApp();
// Adding the plugin is a standard function call
disallowFoo(app);
await app.listen();
The beauty about this kind of API is that many features in Fresh are a just standard middleware, and the more complex ones like our file system router and middleware handler would just call methods on app
. The internals of Fresh will be implemented the exact same way as any other plugin or middleware. This eliminates the mismatch of the current plugin API and the Fresh internals of today.
Simpler middleware signature
The current middleware signature in Fresh has two function arguments:
type Middleware = (req: Request, ctx: FreshContext) => Promise<Response>;
In most of our code we noticed that we rarely need to access the Request
object. So most of our middlewares just skip over it:
// We don't need _req, but still need to define it...
const foo = (_req: Request, ctx: FreshContext) => {
// Do something here
const exists = doSomething();
if (!exists) {
return ctx.renderNotFound();
}
// Respond
return ctx.next();
};
It's a minor thing, but it's a bit annoying that you always have to sorta step over the req
argument. With Fresh 2.0 we're planning to move it onto the context object.
// Fresh 1.x
const foo = (req: Request, ctx: FreshContext) => new Response("hello");
// Fresh 2.0
const foo = (ctx: FreshContext) => new Response("hello");
Same arguments for sync and async routes
Orignally, I've modelled async routes after the middleware signature. That's why they require two arguments:
export default async function Page(req: Request, ctx: RouteContext) {
// ...
}
With the benefit of hindsight, I consider this a mistake. What naturally happens is that folks tend to start out with a synchronous route and add the async
keyword to make it asynchronoues. But this doesn't work in Fresh 1.x.
// User starts out with a sync route
export default function Page(props: PageProps) {
// ...
}
// Later they want to make it async, but this
// breaks in Fresh 1.x :S
export default async function Page(props: PageProps) {
// ...
}
So yeah, we'll correct that. In Fresh 2.0 this will work as expected.
// Sync route same as in Fresh 1.x
export default function Page(props: PageProps) {
// ...
}
// Async route in Fresh 2.0
export default async function Page(props: PageProps) {
// ...
}
True async server-side components
In truth, the async route components in Fresh 1.x are a bit of a beautiful lie. Internally, they are not components, but a plain function that happens to return JSX. We take the returned JSX and pass it to Preact to render and that's all
there is to it. The downside of that approach is that hooks don't work inside the async funciton, because it's not executed in a component context. In fact async routes in Fresh 1.x are called way before the actual rendering starts.
// Fresh 1.x async route
export default async function Page(req: Request, ctx: RouteContext) {
// This breaks, because this function is not a component in Fresh 1.x,
// but rather treated as a function that happens to return JSX
const value = useContext(MyContext);
// ...
}
You might be wondering why we went with this approach and the main reason was that Preact didn't support rendering async components at that time. This was the quickest way to get something that gives you most of the benefits of async components with some tradeoffs into Fresh.
The good news is that Preact recently added support for rendering async components on the server itself. This means we can finally drop our workaround and support rendering async components natively.
// Fresh 2.x async components just work
export default async function Page(ctx: FreshContext) {
// Works as expected in Fresh 2.0
const value = useContext(MyContext);
// ...
}
This isn't restricted to just route components either. With the exception of islands or components used inside islands, any component on the server can be async in Fresh 2. Components in islands cannot be async, because islands are also rendered in the browser and Preact does not support async components there. It's unlikely that it will in the near future either as rendering in the client is much more complex, given that it mostly has to deal with updates which are not a thing on the server.
Simpler error responses
Fresh 1.x allows you two define a _500.tsx
and a _404.tsx
template at the top of your routes folder. This allows you to render a different template when an error occurred. But this has a problem: What if you want to show an error page when a different status code other than 500
or 404
is thrown?
We could always add support for more error templates in Fresh, but ultimately, the heart of the issue is that Fresh does the branching instead of the developer using Fresh.
So in Fresh 2.0 both templates will be merged into one _error.tsx
template.
# Fresh 1.x
routes/
├── _500.tsx
├── _404.tsx
└── ...
# Fresh 2.0
routes/
├── _error.tsx
└── ...
And then inside the _error.tsx
template you can branch and render different content based on the error code if you desire:
export default function ErrorPage(ctx: FreshContext) {
// Exact signature to be determined, but the status
// code will live somewhere in "ctx"
const status = ctx.error.status;
if (status === 404) {
return <h1>This is not the page you're looking for</h1>;
} else {
return <h1>Sorry - Some other error happend!</h1>;
}
}
With only one error template to deal with, this makes it a lot easier to allow them to be put anywhere. So will be able to use different error templates for different parts of your app.
# Fresh 2.0
routes/
├── admin/
│ ├── _error.tsx # different error page for admin routes
│ └── ...
│
├── _error.tsx # error template for everywhere else
└── ...
Adding <head>
elements from Handlers
Whilst not ready for the initial Fresh 2.0 release, we plan to get the guts of Fresh ready for streaming rendering where both the server and client can load data in parallel. Again, this will not be part of the initial 2.0 release, but might land later in the year. But before we can even explore that path, some changes in regards to how Fresh works are required.
With streaming the <head>
portion of an HTML document will be flushed as early as possible to the browser. This breaks the current <Head>
component which allows you to add additional elements into the <head>
-tag from anywhere else in your component tree. By the team the <Head>
component is called, the actual <head>
of the document has long been flushed already to the browser. There is no remaining head on the server we could patch anymore.
So with Fresh 2.0 we'll remove the <Head>
component in favour of a new way of adding elements from a route handler.
// Fresh 2.0
export const handlers = defineHandlers({
GET(ctx) {
const user = await loadUser();
// Handlers in Fresh 2.0 allow you to return either
// a `Response` instance, or a plain object like here
return {
// Will be passed to the component's `props.data`
data: { name: user.name },
// Pass additional elements to `<head>`
head: [
{ title: "My cool Page" },
{ name: "description", content: "this is a really cool page!" },
// ...
],
};
},
});
export default defineRoute<typeof handlers>(async (props) => {
return <h1>User name: {props.data.name}</h1>;
});
EDIT: This API is the least fleshed out of the ones listed here and might change. The main goal we have is to get rid of the <Head>
component.
Appendix
These are the breaking changes we have planned for Fresh 2. Despite some of them being breaking changes updating a Fresh 1.x project to Fresh 2 should be fairly smooth. Although this is quite a long list of bigger features, I spent yesterday and today hacking on it and got way further than I anticipated. Most of the features are already implemented in a branch, but lots of things are still in flux. It's lacking quite a bit of polish too. There will likely be some bigger changes landed in the coming weeks in the main
branch.
It's too early yet to be tried out, but there will be an update soon. Once the actual release date approaches we'll also work on adding an extensive migration document. Given that it's early days and I just started back working on Fresh
yesterday, these things don't exist yet. The details of some of the features listed here and how they will be implemented might change, but I think the rough outline is pretty solid already.
I'm pretty excited about this release because it fixes many long standing issues and massively improves the overall API. Playing around with early prototypes it makes it much more fun to use too. I hope you are similarly excited about this release.