Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[labs/ssr] do we need special ssr only async decorators? #2469

Open
daKmoR opened this issue Jan 29, 2022 · 14 comments
Open

[labs/ssr] do we need special ssr only async decorators? #2469

daKmoR opened this issue Jan 29, 2022 · 14 comments
Assignees

Comments

@daKmoR
Copy link
Contributor

daKmoR commented Jan 29, 2022

Description

I for example quite often inlining svgs when generating html

const headerTemplate =() => html`
  <button id="mobile-menu-trigger" data-action="trigger-mobile-menu">
    <span class="sr-only">Show Menu</span>
     ${readFile(new URL('../assets/burger-menu.svg', import.meta.url), 'utf8').then(res => res.toString())}
  </button>
`;

expected output

<button id="mobile-menu-trigger" data-action="trigger-mobile-menu">
  <span class="sr-only">Show Menu</span>
  <svg>...</svg>
</button>

actual output

<button id="mobile-menu-trigger" data-action="trigger-mobile-menu">
  <span class="sr-only">Show Menu</span>
  [object Promise]
</button>

now there are 2 issues

  • it's async which does render as [object Promise]
  • Using the until won't help as in ssr there is only one render
  • it uses fs to access a file on the filesystem which you obviously can't do in the browser

some (crazy) ideas

  • have a special ssr directive that only executes in ssr but is "static" on the client side?
  • have a special ssr async directive that "blocks" the rendering until it's resolved so you get the value you want in a single pass?

what do you think?

Additional info

a simplified version

function* generator(i) {
  yield i;
  yield i + 10;
}

const template = ({ title, lang }) => html`
  <p>${title}</p>
  ${new Promise((res) => { setTimeout(res('resolved-promise'), 10)})}
  ${generator(1)}
`;

expected

<p>hello</p>
resolved-promise
1

actual

<p>hello</p>
[object Promise]
[object Generator]
@aomarks
Copy link
Member

aomarks commented Jan 29, 2022

some (crazy) ideas

  • have a special ssr directive that only executes in ssr but is "static" on the client side?
  • have a special ssr async directive that "blocks" the rendering until it's resolved so you get the value you want in a single pass?

Doesn't seem crazy to me :)

How about something like this?

import {whenSsr} from 'lit/ssr.js';

const dataPromise = fetch(...).then(...);
// When server-side rendering, wait for dataPromise to resolve and render that.
// When client-side rendering, render "Loading..." initially, then dataPromise after it resolves.
html`<p>${whenSsr(dataPromise, until(dataPromise, "Loading..."))}</p>`

whenSsr could work by setting a special property on the value. In the browser, the first parameter would have no effect.

export const whenSsr = (ssrPromise, value) => {
  value['$lit-ssr-blocking-promise$'] = ssrPromise;
  return value;
}

During SSR, we'd check for the special property, and if it's present, block rendering until it resolves, and render the resolved result:

async function* renderValue(value: unknown, renderInfo: RenderInfo) {
  ...
  const blockingPromise = value['$lit-ssr-blocking-promise$'];
  if (blockingPromise !== undefined) {
    yield *renderValue(await blockingPromise, renderInfo);
  }
}

You could maybe imagine directives directly setting the $lit-ssr-blocking-promise$ property in some cases too. For example, if until participated in this protocol, maybe you'd "flag" the SSR-blocking promise instead of using whenSsr:

import {until} from 'lit/directives/until.js';
import {blocksSsr} from 'lit/ssr.js';

html`<p>${until(blocksSsr(dataPromise), "Loading...")}</p>`

With an implementation something like this:

export class UntilDirective extends AsyncDirective {
  ...
  render(...args: Array<unknown>) {
    this['$lit-ssr-blocking-promise$'] = args.find((x) => x['$flagged-by-blocks-ssr$']);
    return args.find((x) => !isPromise(x)) ?? noChange;
  }
}

(We probably don't want to do that specifically because it would add overhead to every until call, but just to illustrate the idea).

@daKmoR
Copy link
Contributor Author

daKmoR commented Jan 29, 2022

whenSsr looks good to me

if we only have a promise on the server-side and none on the client side then it would work sort of like this?

import { readFile } from 'fs/promises';

const serverPromise = new Promise(resolve => {
  readFile(new URL('../assets/burger-menu.svg', import.meta.url), 'utf8').then(
    read => resolve(read.toString())
  );
});

// When server-side rendering, wait for dataPromise to resolve and render that.
// When client-side it will render treat it static content
html`<p>${whenSsr(serverPromise)}</p>`

ssr output would be

<p><svg>...</svg></p>

so no parts inside? 🤔

@aomarks
Copy link
Member

aomarks commented Jan 30, 2022

I think in that example you'd also need unsafeHTML, or else the SVG will be rendered as an escaped string.

By the way, are you writing templates that you only need to run on the server? Obviously in your example, the fs/promises import would fail if you tried to run this in a browser.

Also, this is probably stating the obvious, but the simplest way you could handle async data is by taking it as a parameter to your template and doing the async work before you start rendering:

const myTemplate = (data) => html`<p>${data}</p>`

const myHttpHandler = async (ctx) {
  const data = await getSomeData();
  const ssrResult = render(myTemplate(data));
  context.body = Readable.from(ssrResult);
}

I think the advantage of a whenSsr kind of API, though, is that it would provide a way to pause mid-render, having streamed as much of the preceding HTML as possible before having to wait for async work to complete, which can improve performance.

@daKmoR
Copy link
Contributor Author

daKmoR commented Jan 30, 2022

yes indeed

  • there are server only templates (these will never be hydrated)
  • there are templates that have certain parts as server only but other parts might still be hydrated
  • there are templates who are meant to be fully hydrated

yes, the example above would need unsafeHTML...

right now I moved all async stuff outside the template, which works in most cases...
but if you have something async that depends on the input data then it becomes tricky (or inconvenient)

import { readFile } from 'fs/promises';
import { unsafeHTML } from 'lit/...';

function getSvg(name) {
  return new Promise(resolve => {
    readFile(new URL(`../assets/${name}.svg`, import.meta.url), 'utf8').then(
      read => resolve(unsafeHTML(read.toString()))
    );
  });
});
  

const myTemplate = ({ svgName }) => html`<p>${whenSsr(getSvg(svgName))}</p>`

@bennypowers
Copy link
Collaborator

Hey this looks really cool!

I'm thinking from a user's perspective, we'd want to avoid requiring extra syntax and steps, so what do you think of this:

  • the ssr environment converts all interpolations to whenSsr directives, and awaits them
  • the server, when creating the hydration bundle, replaces all the whenSsr calls with the promises' resolved values
  • if the user wants to explicitly leave an until call in the template for the client to handle, they use until (or, I dunno, some clientUntil)
    • this is parallel to the current client-side-js experience where promise-resolving-in-templates is opt-in (using until)

The user's goals here:

  • all server-side promises are always resolved
  • explicitly sending until across to the client is opt-in, because sometimes users want until in their templates

@justinfagnani
Copy link
Collaborator

I think we have a few items to design together here to make this work properly. The most important part to me is around imports. We need SSR-only logic to live in SSR-only modules, and I'd like to do that without custom code transformations. The best thing I can think of right now is for client-code with SSR-only deps to use guarded dynamic imports that can be dead-code eliminated in various tools:

import {isSsr, serverUntil} from '@lit-labs/ssr-client';

const renderSvgServer = async (svgName) => {
  const {readFile} = await import('fs/promises');
  return serverUntil(readFile(...));
};

const renderSvg = isSsr ? renderSvgServer : () => noChange;

export const myTemplate = ({svgName}) => html`<div>${renderSvg(svgName)</div>`;

I'm sure this is wrong in several ways, but the idea is this would run fine without any transformations, and that you could run this through Terser and replace isSsr with false and it would dead code eliminate renderSvgServer and reduce renderSvg to () => noChange.

@aomarks aomarks self-assigned this Feb 1, 2022
@maartenst
Copy link

for now the isSsr can probably be worked around by checking for things not (yet) supported in SSR, like window.navigator?

@daKmoR
Copy link
Contributor Author

daKmoR commented Mar 26, 2022

hmmm I just ran into this again 😅

not loading any server side code statically totally makes sense 👍

In this case I can not work around it... as the async server code depends on a property of the element 😭

export class OpenGraphOverview extends LitElement {
  static properties = {
    urls: { type: String },
    inputDir: { type: String, attribute: 'input-dir' },
  };

  render() {
    const iframeUrl = this.url.endsWith('/')
      ? `${this.url}index.opengraph.html`
      : this.url.replace(/\.html$/, '.opengraph.html');
      
    return html`
      <div>
          <a href="vscode://${hydrateAbleUrlToSourceFilePath(url, this.inputDir)}">
            <server-icon icon="solid/laptop-code"></server-icon>
          </a>
          <iframe src="${iframeUrl}" loading="lazy" width="1200" height="628"></iframe>
      </div>
    `;      
}

const isSsr = window && window.navigator ? false : true;

async function serverUrlToSourceFilePath(url, inputDir) {
  const { urlToSourceFilePath } = await import('@rocket/engine');
  // urlToSourceFilePath is a function that searched the file system for potential matches
  // e.g. it's async & needs access to the file system
  return serverUntil(urlToSourceFilePath(url, inputDir));
}

const hydrateAbleUrlToSourceFilePath = isSsr ? serverUrlToSourceFilePath : () => noChange;

on the server side I can then render it like this

export default () => html`
  <opengraph-overview
    urls="${JSON.stringify(urls)}"
    input-dir=${new URL('./', import.meta.url).href}
  ></opengraph-overview>
`;

which result in this

<opengraph-overview 
  urls="[&quot;/&quot;,&quot;/docs/&quot;,&quot;/docs/setup/&quot;]" 
  input-dir="file:///html/rocket/site/pages/">
  <!--lit-node 1-->
</opengraph-overview>

which I guess could then be hydrated 🤔

Soooo I think this will need a few steps to make it work 😅

1. some way to execute an async code via ssr

As a first step having a serverUntil makes sense. What would it take to make it happen?
I'm that is the most important part as this will need to be backed into lit-ssr. and there are you could use.

Impact: No user code workarounds possible => this is actually blocking me right now

2. Best practices for how to make hydratable server functions

Show and explain how static imports to server needing function break hydration. So if you want to hydrate you need to use a dynamic import for server functions.

Impact: You can already do this yourself right now - so not blocking.

3. Streamline

Once we have it working we can look into improving the developer experience.
Some potential options

  • until behaves like serverUntil while using ssr and later does nothing?
  • automatic wrapper helpers to dynamically load server functions
  • ...

Impact: It's a nice to have for now.

@PonomareVlad
Copy link

At the moment, I have the same problem — fetching data from the DB (usually this is an async operation 🙂)

I have two examples of async server-side rendering:

The second option makes it possible to get a completely isomorphic components, but with a certain set of hydration bugs (the branch needs to be updated 🔥)

Hope this helps you move forward !

@steveworkman
Copy link
Contributor

Can we work around any of this by making use of the Task labs package? Extend this to have an SSR state beyond the regular states would give a nice interface to manage server-side async functions

@aomarks aomarks removed their assignment Jun 10, 2022
@augustjk augustjk changed the title [ssr] do we need special ssr only async decorators? [labs/ssr] do we need special ssr only async decorators? Oct 13, 2022
@thescientist13
Copy link
Contributor

Just wanted to chime in as well that being able to do some (exclusive) async work on the server would be really nice. Similar to other SSR only use cases cited here, I've liked using the Component model to even do "pages", and so being able to do some async data fetching would be really nice, for example

import { html, LitElement } from 'lit';
import { getProducts } from '../services/products.js';
import '../components/card.js';

export default class ProductsPage extends LitElement {
  constructor() {
    super();
    this.products = [];
  }

  async connectedCallback() {
    super.connectedCallback();

    this.products = await getProducts();
  }

  render() {
    const { products } = this;

    return html`
      ${
        products.map((product) => {
          const { title, thumbnail } = product;

          return html`
            <app-card
              title="${idx + 1}) ${title}"
              thumbnail="${thumbnail}"
            ></app-card>
          `;
        })
      }
    `;
  }
}

In the above case maybe the presence / hint of an async connectedCallback could be an option? I saw the recommendation to use serverUntil so will give that a try in my workflow, but just figured I would share my experience.

@justinfagnani
Copy link
Collaborator

Task should be the kind of thing that can help here. If we can choose server vs client implementations of getProducts(), and SSR can wait on Task completion, then we should have the pieces. We'd also want a way to use initial data from a server-made data bundle. not sure if that should be the responsibility of Task or some wrapper for client/server loader functions.

@thescientist13
Copy link
Contributor

Thanks @justinfagnani , will take a look into Task we well.

@AndrewJakubowicz
Copy link
Contributor

AndrewJakubowicz commented Nov 16, 2023

ServerController PR: #4390

I've been working on adding a server only hook to the ReactiveController interface (called ServerController) which unblocks and allows "async server code to depend on a property of the element" (#2469 (comment)).

My PR also enables extending @lit/task very minimally such that it is awaited and resolved on the server. E.g.:

import {Task} from '@lit/task';
//...
class ServerLoadedTask extends Task implements ServerController {
  get serverUpdateComplete() {
    this.run();
    return this.taskComplete;
  }
}

In the future we'd ideally like the bake this into the Task package itself. But for now it would be possible in userland.

Other patterns I've explored are the need for SSR only directives. With a ServerController this can be worked around by doing your server only async work on the controller, and assigning the result of the work as state on the controller (e.g. result of reading a file). The element's render() method runs after all server controllers have resolved, allowing usage of their resolved state. This state can then be used within directives or anywhere else in the template.

On the client, in order to not destroy the server rendered content, it's ideal to use noChange. This tells Lit to leave the previously rendered (SSR) content alone.

I'd appreciate anyone taking a look and leaving feedback if something stands out.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Status: 📋 Triaged
Development

No branches or pull requests

9 participants