analogjs / analog

The fullstack meta-framework for Angular. Powered by Vite and Nitro
https://analogjs.org
MIT License
2.57k stars 245 forks source link

RFC: Introduce server-side data fetching for components #197

Closed brandonroberts closed 1 year ago

brandonroberts commented 1 year ago

Which scope/s are relevant/related to the feature request?

platform

Information

This would add inline/external for components to fetch data, and be exposed to the component through an injectable function.

Requirements

Inline

// page.component.ts
import { AsyncPipe, JsonPipe } from "@angular/common";
import { Component } from "@angular/core";
import db from './db';

import { useLoader } from "@analogjs/router";

export const loader = async() => {
  const items = db.getItems();

  return {
    items: items
  };
};
@Component({
  selector: 'app-test',
  standalone: true,
  imports: [AsyncPipe, JsonPipe],
  template: `
    Test works

    {{ data$ | async | json }}
  `
})
export default class TestComponent {
  data$ = useLoader<typeof loader>(); // <-- { items: Item[] }
}

External

// page.server.ts
import db from './db';

export const loader = async() => {
  const items = db.getItems();

  return {
    items: items
  };
};
// page.component.ts
import { AsyncPipe, JsonPipe } from "@angular/common";
import { Component } from "@angular/core";
import { useLoader } from "@analogjs/router";

@Component({
  selector: 'app-test',
  standalone: true,
  imports: [AsyncPipe, JsonPipe],
  template: `
    Test works

    {{ data$ | async | json }}
  `
})
export default class TestComponent {
  data$ = useLoader<typeof loader>(); // <-- { items: Item[] }
}

Describe any alternatives/workarounds you're currently using

No response

I would be willing to submit a PR to fix this issue

marcus-sa commented 1 year ago

What about dependency injection? It would've been great to define loaders as route resolvers or methods on components.

brandonroberts commented 1 year ago

Dependency injection works as normal on the client side, as it's a resolver that calls a backend API. The useLoader is a wrapper around the ActivatedRoute and a resolve property. Nothing too complex there to start. Loaders on route components feel too invasive and require going outside the normal component path

marcus-sa commented 1 year ago

Dependency injection works as normal on the client side, as it's a resolver that calls a backend API. The useLoader is a wrapper around the ActivatedRoute and a resolve property. Nothing too complex there to start. Loaders on route components feel too invasive and require going outside the normal component path

I'm referring to inversion of control/dependency injection on the server side :)

brandonroberts commented 1 year ago

Ahh, no its not doing that. I don't see much value in having DI there as you would mostly be using imports to access server-only code like Prisma, file-system access, etc.

marcus-sa commented 1 year ago

Ahh, no its not doing that. I don't see much value in having DI there as you would mostly be using imports to access server-only code like Prisma, file-system access, etc.

I don't necessarily agree with that statement. I've used Remix extensively, to build a subscription-based content creation platform, and it's a pain in the ass without DI on the server side. The amount of boilerplate code and workarounds required is crazy. Angular already supports DI for SSR, so I don't see why loaders shouldn't.

brandonroberts commented 1 year ago

If you have some more concrete examples that would be great. Angular DI in SSR also assumes you're using the component as an API endpoint though, correct? I'm not intending to run Angular components on the server (yet πŸ˜‰), so I'm trying to see where DI would fit in

goetzrobin commented 1 year ago

How hard would it be to do something similar to NextJs and provide two loaders:

  1. Like getServerSideProps who runs on every new request
  2. Like getStaticProps which runs only on build time when static pages are built

Do you think there’s merit in doing that? Interested to hear your opinion!

brandonroberts commented 1 year ago

Those features are worth considering, but we don't necessarily have to mimic them as-is. NextJS is moving away from getServerSideProps and getStaticProps also with the 13.x release and app directory.

goetzrobin commented 1 year ago

I didn't even know that until you pointed it out! Seems like the tree shaken loader that you describe would be perfect then. I need to try it, but I assume that Nitro automatically serves a pre-rendered route and if no static html exists it generates the page on each request. Then, having two functions definitely seems unnecessary.

I don't want to hijack this RFC with (sort of) unrelated things but looking at this NextJS documentation and this configuration option for Nitro made me think that it would be cool if you could add some config snippet (like 'use static'; or 'use swc'; ) to the top of the file and based on that the route would be marked as static, cached, etc.

ashley-hunter commented 1 year ago

Very much looking forward to this feature being available! Rich Harris had a recent talk that covered some of the important details and downfalls of these approaches which might be worth considering when deciding on the approach to take:

https://youtu.be/uXCipjbcQfM?t=1070

Key points:

brandonroberts commented 1 year ago

Thanks @ashley-hunter, I'll take a look. I've also been watching projects like TanStack Bling and I'm really interested in adopting this pattern. It supports server-only functions, as well as server-only imports.

You use the fetch$ function to wrap your call in, it returns you an RPC function to call using fetch, but everything inside the function is run on the server and not on the client.

import { fetch$ } from '@tanstack/bling'

const fetchFn = fetch$(async (payload) => {
  // do something
  return 'result'
})

fetchFn() <- Promise<string>

In Angular, we already have HttpClient so the thought is to have something like http$

import { http$ } from '@analogjs/platform/something';

const httpFn = http$(async (payload) => {
  // do something
  return 'result'
})

httpFn() <- Promise<string>

Where HttpClient does the work internally instead of fetch, so we could potentially take advantage of automatic cache transfer with Angular v16. Using the "loader" pattern would still be an option

// page.component.ts
import { AsyncPipe, JsonPipe } from "@angular/common";
import { Component } from "@angular/core";
import db from './db';
import { http$ } from '@analogjs/platform/something';

// runs on server
export const loader = http$(() => {
  const items = db.getItems();

  return {
    items: items
  };
});

Thoughts?

marcus-sa commented 1 year ago

Thanks @ashley-hunter, I'll take a look. I've also been watching projects like TanStack Bling and I'm really interested in adopting this pattern. It supports server-only functions, as well as server-only imports.

You use the fetch$ function to wrap your call in, it returns you an RPC function to call using fetch, but everything inside the function is run on the server and not on the client.

import { fetch$ } from '@tanstack/bling'

const fetchFn = fetch$(async (payload) => {
  // do something
  return 'result'
})

fetchFn() <- Promise<string>

In Angular, we already have HttpClient so the thought is to have something like http$

import { http$ } from '@analogjs/platform/something';

const httpFn = http$(async (payload) => {
  // do something
  return 'result'
})

httpFn() <- Promise<string>

Where HttpClient does the work internally instead of fetch, so we could potentially take advantage of automatic cache transfer with Angular v16. Using the "loader" pattern would still be an option

// page.component.ts
import { AsyncPipe, JsonPipe } from "@angular/common";
import { Component } from "@angular/core";
import db from './db';
import { http$ } from '@analogjs/platform/something';

// runs on server
export const loader = http$(() => {
  const items = db.getItems();

  return {
    items: items
  };
});

Thoughts?

So https://qwik.builder.io ?

brandonroberts commented 1 year ago

@marcus-sa not exactly. Qwik is much more fine-grained with its use of module extraction across the board. This isn't supported in Angular today, so this is isolated to data fetching

ashley-hunter commented 1 year ago

I really enjoy the simplicity that can be achieved through using TRPC, and compared to traditional, type-unsafe REST calls, reverting back to them can be quite painful. So I'm strongly in favour of a straightforward and type-safe approach like the one you are proposing.

I find the approach adopted by SvelteKit, where the code executed on the server is located in a distinct file labelled with .server in its name, to be quite nice. This approach has several advantages:

But looking forward to a feature like this, will be very useful!

brandonroberts commented 1 year ago

Definitely some good points in the talk to be considered! I like the .server files approach also. It would keep the Angular components clean and maybe less confusing for those coming to use Analog

brandonroberts commented 1 year ago

Draft PR is up https://github.com/analogjs/analog/pull/446. Decided to go with the separate .server.ts support.

goetzrobin commented 1 year ago

Draft PR is up https://github.com/analogjs/analog/pull/446. Decided to go with the separate .server.ts support.

This is awesome! Just for my understanding the load function would be executed on the server and transfer the data via TransferState?

Also, is it possible to pass the H3 event as a param to the load function?

So excited for this πŸš€πŸš€πŸš€πŸš€πŸš€

brandonroberts commented 1 year ago

Draft PR is up #446. Decided to go with the separate .server.ts support.

This is awesome! Just for my understanding the load function would be executed on the server and transfer the data via TransferState?

Also, is it possible to pass the H3 event as a param to the load function?

So excited for this πŸš€πŸš€πŸš€πŸš€πŸš€

Yes. The load function/resolver is like an RPC. Its called through an API endpoint on the server via HttpClient, and returns the results. That way it can be used with TransferState also without any extra work.

The H3 event is passed to the load function also. Need to add some types for the load function also

On the server call

load({
  params: event.context.params, // params/queryParams from the request 
  req: event.node.req, // H3 Request
  res: event.node.res, // H3 Response
  fetch: $fetch // allows you to call API internal endpoints without an additional network request
});
// index.server.ts

export const load = async ({ req, res, params, fetch }) => {
  return {
    data: true,
  };
};
marcus-sa commented 1 year ago

Draft PR is up #446. Decided to go with the separate .server.ts support.

This is awesome! Just for my understanding the load function would be executed on the server and transfer the data via TransferState? Also, is it possible to pass the H3 event as a param to the load function? So excited for this πŸš€πŸš€πŸš€πŸš€πŸš€

Yes. The load function/resolver is like an RPC. Its called through an API endpoint on the server via HttpClient, and returns the results. That way it can be used with TransferState also without any extra work.

The H3 event is passed to the load function also. Need to add some types for the load function also

On the server call

load({
  params: event.context.params, // params/queryParams from the request 
  req: event.node.req, // H3 Request
  res: event.node.res, // H3 Response
  fetch: $fetch // allows you to call API internal endpoints without an additional network request
});
// index.server.ts

export const load = async ({ req, res, params, fetch }) => {
  return {
    data: true,
  };
};

It'd be great if it could be abstracted away so that one could use Deepkt RPC for example.