Thanks to visit codestin.com
Credit goes to Github.com

Skip to content

Conversation

@AndrewJakubowicz
Copy link
Contributor

@AndrewJakubowicz AndrewJakubowicz commented Nov 11, 2023

Part of issue: #2469
Change split out of and adapted from #1690

Context

Add a new interface ServerController:

interface ServerController {
  serverUpdateComplete: Promise<unknown>;
}

Any ReactiveController that implements this interface will cause SSR to yield and await serverUpdateComplete.

This unlocks some new async SSR patterns.

Design

LitElementRenderer now pulls the list of ReactiveControllers of the LitElement it is about to SSR, and then awaits all controllers implementing ServerController before rendering the contents of the element.

In order to access the ReactiveControllers on a LitElement a new getControllers function has been exposed via _$LE. A LitElement's private set of controllers is now bound to the stable minified identifier W.

Unlocks the following new SSR use cases:

  • @lit/task can be extended to run during SSR. Allowing the SSR'd content to contain the completed Task data.
/**
  * Example of a Task which runs on the server, SSR'ing the resulting value.
  */
class ServerLoadedTask extends Task implements ServerController {
  get serverUpdateComplete() {
    this.run();
    return this.taskComplete;
  }
}
  • Server reactive controllers can be implemented which do server specific work during SSR (read files, database queries, fetch, ...). These controllers have access to their components properties.

Although this doesn't implement the async directives, it is now possible to write custom controllers which vend directives that can depend on async work being done. It is also possible to do async work which directives then synchronously use in the render.

Demo

The demo has been updated to show an integration of @lit/task with SSR, which can be tested with npm run demo:global from the /labs/ssr directory.

Test plan

Added SSR tests that highlight ReactiveController and ServerController basic cases. I've also added a test which shows how to implement this use case.

Risks

There is an issue with SSR that this PR can also demonstrate. And that is server render / hydration data mismatch.
The simple case of the issue can be reproduced by rendering from a element:

  render() {
    return isServer ? "some server rendered stuff" : "some client rendered stuff";
  }

This can result in a rendered mismatch between server / client. This becomes even more apparent if you have a situation where a server only controller renders something on the server, and then tries to render something else on the client.
User's must currently be aware of this until we resolve #1434

@changeset-bot
Copy link

changeset-bot bot commented Nov 11, 2023

🦋 Changeset detected

Latest commit: c75f9a6

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@lit/reactive-element Patch
lit-element Patch
@lit-labs/ssr-client Minor
@lit-labs/ssr Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions
Copy link
Contributor

github-actions bot commented Nov 11, 2023

📊 Tachometer Benchmark Results

Summary

nop-update

  • this-change, tip-of-tree, previous-release: unsure 🔍 -9% - +2% (-1.07ms - +0.24ms)
    this-change vs tip-of-tree

render

  • this-change: 49.24ms - 51.89ms
  • this-change, tip-of-tree, previous-release: unsure 🔍 -6% - +1% (-1.23ms - +0.14ms)
    this-change vs tip-of-tree
  • this-change, tip-of-tree, previous-release: unsure 🔍 -2% - +2% (-0.62ms - +0.71ms)
    this-change vs tip-of-tree
  • this-change, tip-of-tree, previous-release: unsure 🔍 -4% - +0% (-1.44ms - +0.15ms)
    this-change vs tip-of-tree

update

  • this-change: 536.57ms - 547.72ms
  • this-change, tip-of-tree, previous-release: unsure 🔍 -10% - +1% (-4.14ms - +0.60ms)
    this-change vs tip-of-tree
  • this-change, tip-of-tree, previous-release: unsure 🔍 -2% - +1% (-1.23ms - +1.06ms)
    this-change vs tip-of-tree
  • this-change, tip-of-tree, previous-release: unsure 🔍 -1% - +2% (-2.92ms - +9.22ms)
    this-change vs tip-of-tree

update-reflect

  • this-change: 530.14ms - 537.69ms
  • this-change, tip-of-tree, previous-release: unsure 🔍 -1% - +1% (-3.24ms - +5.45ms)
    this-change vs tip-of-tree

Results

this-change

render

VersionAvg timevs
49.24ms - 51.89ms-

update

VersionAvg timevs
536.57ms - 547.72ms-

update-reflect

VersionAvg timevs
530.14ms - 537.69ms-
this-change, tip-of-tree, previous-release

render

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
17.97ms - 18.68ms-unsure 🔍
-6% - +1%
-1.23ms - +0.14ms
unsure 🔍
-7% - +0%
-1.36ms - +0.03ms
tip-of-tree
tip-of-tree
18.29ms - 19.46msunsure 🔍
-1% - +7%
-0.14ms - +1.23ms
-unsure 🔍
-5% - +4%
-0.95ms - +0.71ms
previous-release
previous-release
18.40ms - 19.59msunsure 🔍
-0% - +7%
-0.03ms - +1.36ms
unsure 🔍
-4% - +5%
-0.71ms - +0.95ms
-

update

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
38.72ms - 41.55ms-unsure 🔍
-10% - +1%
-4.14ms - +0.60ms
unsure 🔍
-11% - +1%
-4.52ms - +0.34ms
tip-of-tree
tip-of-tree
40.00ms - 43.80msunsure 🔍
-2% - +10%
-0.60ms - +4.14ms
-unsure 🔍
-7% - +6%
-3.06ms - +2.42ms
previous-release
previous-release
40.25ms - 44.20msunsure 🔍
-1% - +11%
-0.34ms - +4.52ms
unsure 🔍
-6% - +7%
-2.42ms - +3.06ms
-

nop-update

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
11.04ms - 12.15ms-unsure 🔍
-9% - +2%
-1.07ms - +0.24ms
unsure 🔍
-8% - +4%
-0.89ms - +0.47ms
tip-of-tree
tip-of-tree
11.67ms - 12.35msunsure 🔍
-2% - +9%
-0.24ms - +1.07ms
-unsure 🔍
-3% - +6%
-0.31ms - +0.72ms
previous-release
previous-release
11.42ms - 12.19msunsure 🔍
-4% - +8%
-0.47ms - +0.89ms
unsure 🔍
-6% - +3%
-0.72ms - +0.31ms
-
this-change, tip-of-tree, previous-release

render

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
34.10ms - 35.08ms-unsure 🔍
-2% - +2%
-0.62ms - +0.71ms
unsure 🔍
-2% - +2%
-0.63ms - +0.75ms
tip-of-tree
tip-of-tree
34.09ms - 34.99msunsure 🔍
-2% - +2%
-0.71ms - +0.62ms
-unsure 🔍
-2% - +2%
-0.65ms - +0.67ms
previous-release
previous-release
34.05ms - 35.01msunsure 🔍
-2% - +2%
-0.75ms - +0.63ms
unsure 🔍
-2% - +2%
-0.67ms - +0.65ms
-

update

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
71.18ms - 73.00ms-unsure 🔍
-2% - +1%
-1.23ms - +1.06ms
unsure 🔍
-2% - +1%
-1.70ms - +0.94ms
tip-of-tree
tip-of-tree
71.48ms - 72.88msunsure 🔍
-1% - +2%
-1.06ms - +1.23ms
-unsure 🔍
-2% - +1%
-1.48ms - +0.90ms
previous-release
previous-release
71.51ms - 73.43msunsure 🔍
-1% - +2%
-0.94ms - +1.70ms
unsure 🔍
-1% - +2%
-0.90ms - +1.48ms
-
this-change, tip-of-tree, previous-release

render

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
33.20ms - 34.04ms-unsure 🔍
-4% - +0%
-1.44ms - +0.15ms
unsure 🔍
-4% - +1%
-1.27ms - +0.32ms
tip-of-tree
tip-of-tree
33.59ms - 34.94msunsure 🔍
-0% - +4%
-0.15ms - +1.44ms
-unsure 🔍
-2% - +3%
-0.78ms - +1.12ms
previous-release
previous-release
33.42ms - 34.77msunsure 🔍
-1% - +4%
-0.32ms - +1.27ms
unsure 🔍
-3% - +2%
-1.12ms - +0.78ms
-

update

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
543.72ms - 554.09ms-unsure 🔍
-1% - +2%
-2.92ms - +9.22ms
unsure 🔍
-1% - +2%
-3.68ms - +8.34ms
tip-of-tree
tip-of-tree
542.59ms - 548.92msunsure 🔍
-2% - +1%
-9.22ms - +2.92ms
-unsure 🔍
-1% - +1%
-5.22ms - +3.57ms
previous-release
previous-release
543.53ms - 549.62msunsure 🔍
-2% - +1%
-8.34ms - +3.68ms
unsure 🔍
-1% - +1%
-3.57ms - +5.22ms
-

update-reflect

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
543.75ms - 549.47ms-unsure 🔍
-1% - +1%
-3.24ms - +5.45ms
unsure 🔍
-1% - +1%
-5.22ms - +3.97ms
tip-of-tree
tip-of-tree
542.23ms - 548.78msunsure 🔍
-1% - +1%
-5.45ms - +3.24ms
-unsure 🔍
-1% - +1%
-6.60ms - +3.13ms
previous-release
previous-release
543.64ms - 550.84msunsure 🔍
-1% - +1%
-3.97ms - +5.22ms
unsure 🔍
-1% - +1%
-3.13ms - +6.60ms
-

tachometer-reporter-action v2 for Benchmarks

@github-actions
Copy link
Contributor

github-actions bot commented Nov 11, 2023

The size of lit-html.js and lit-core.min.js are as expected.

@AndrewJakubowicz AndrewJakubowicz changed the title [labs/ssr] Add serverUpdateComplete promise to ReactiveController interface for SSR async work [labs/ssr] Add ServerController interface allowing async controller work during SSR Nov 14, 2023
@AndrewJakubowicz AndrewJakubowicz changed the title [labs/ssr] Add ServerController interface allowing async controller work during SSR [labs/ssr] Add ServerController interface allowing async work during SSR Nov 14, 2023
@AndrewJakubowicz AndrewJakubowicz marked this pull request as ready for review November 16, 2023 23:06
Copy link
Member

@augustjk augustjk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a few nits and comments but looks good to me!

Comment on lines +1 to +4
---
'@lit/reactive-element': patch
'lit-element': patch
---
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
---
'@lit/reactive-element': patch
'lit-element': patch
---
---
'@lit/reactive-element': patch
'lit-element': patch
'lit': patch
---

'lit-element': patch
---

The private reactive controller array now minifies to a stable identifier for use with ServerControllers.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are part of the private-ssr-support so maybe should be marked as internal. Users shouldn't really be relying on this.

Suggested change
The private reactive controller array now minifies to a stable identifier for use with ServerControllers.
**Internal**: The private reactive controller array now minifies to a stable identifier for use with ServerControllers.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, this does mean that this version of @lit-labs/ssr now has minimum version requirement. We need to make sure to reflect that when doing the release.

'@lit-labs/ssr': minor
---

Add `ServerController` interface which extends reactive controllers. Any _server only_ async SSR work can now be executed by defining a `serverUpdateComplete` field on a reactive controller. When SSRing a LitElement, its shadow contents will only render after all attached server controller's promises have resolved.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Add `ServerController` interface which extends reactive controllers. Any _server only_ async SSR work can now be executed by defining a `serverUpdateComplete` field on a reactive controller. When SSRing a LitElement, its shadow contents will only render after all attached server controller's promises have resolved.
Add `ServerController` interface which extends reactive controllers. Any _server only_ async SSR work can now be executed by defining a `serverUpdateComplete` field on a reactive controller. When SSRing a LitElement, its shadow contents will only render after all attached server controllers' promises have resolved.

private fetchJson = new ServerLoadedTask(this, {
task: async ([url], {signal}) => {
const response = await fetch(url as string, {signal});
return await response.json();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would allow the type of list for the complete render function to correctly have type inference.

Suggested change
return await response.json();
return await response.json() as Array<{name: string};

>(getControllers(this.element) ?? [])
.map((c: Partial<ServerController>) => c.serverUpdateComplete)
.filter((p: Promise<unknown> | undefined) => !!p);
if (serverControllerUpdatePromises?.length > 0) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is the ? here necessary? Feels like it should always be something even if it's an empty array.

Comment on lines +5525 to +5526
// To satisfy the ReactiveController interface.
declare hostConnected: undefined;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

interesting. i guess even if they're all optional, it expects at least one of the lifecycle methods to have been implemented to satisfy the interface, none of which gets called server side.
i guess this or a no-op method would be fine too.

}, /Server-only templates don't support element parts/);
});

test('all ServerControllers must resolve for an element to complete rendering', async () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice tests!

@sorvell
Copy link
Member

sorvell commented Nov 17, 2023

Before committing to this direction, I think it's probably a good idea to have a plan for how to address async directives and server only templates.

Also, pushing this logic into controllers feels slightly odd since controllers may or may not have anything to do with being async or affecting server rendering.

It seems more natural to let an element itself make this decision. Random idea: await the result of update on the server.

async update(changed) {
  if (isServer) {
    await this.task.taskComplete;
    await this.otherAsyncThingComplete;
  }
  super.update(changed);
}

@AndrewJakubowicz
Copy link
Contributor Author

The intention for this feature is to provide a low level utility which can later be used by controllers (such as Task) to do async work during SSR (without the user needing to modify their element).

The multiple highlighted async use-cases that this specific PR unlocks is mostly because Controllers will initially be the one place where async work can happen. We're starting there because we want to start proving async work out starting with Task. We suspect that there will be some subtlety in doing async work well, and so we'll want to package that up into reusable pieces, and controllers seem like a good way of doing that.

Is there concern that adding this capability in ReactiveControllers may block server directives or awaiting on the element?

@sorvell
Copy link
Member

sorvell commented Nov 18, 2023

The concern is about exposing the API at the right level where knowledge exists about what to do on the server. It seems like this makes more sense at the element level than the controller.

The intention for this feature is to provide a low level utility which can later be used by controllers (such as Task) to do async work during SSR (without the user needing to modify their element).

Some tasks should be awaited for server rendering, others should not. Although this might be determined by a specialized task itself, it's more likely this depends on the setup in the consuming element... unless I'm missing something about the intended automation.

By analogy, you wouldn't add a feature to a self-driving car that allowed a wheel to automatically stop the car, instead you'd let it report speed and tire pressure so the system could decide what makes sense to do...

@rictic
Copy link
Collaborator

rictic commented Nov 20, 2023

@sorvell Yeah, not all Tasks will want to run on the server, that'll be a config. And agreed that elements should be able to do async work on the server too, but currently what does async work look like in an element? Because the server should be doing fundamentally the same thing as the client, just optionally it'll await it before first render. And right now our best pattern for async work on an element is the Task controller, so it makes sense to target that as the first API, IMO.

Copy link
Collaborator

@rictic rictic left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent code, excellent tests!

I'd like to hear out Steve's perspective before merging, but this is otherwise good to me

Partial<ServerController>
>(getControllers(this.element) ?? [])
.map((c: Partial<ServerController>) => c.serverUpdateComplete)
.filter((p: Promise<unknown> | undefined) => !!p);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.filter((p: Promise<unknown> | undefined) => !!p);
.filter((p: Promise<unknown> | undefined) => p != null);

Prefer the more specific comparison

@sorvell
Copy link
Member

sorvell commented Nov 20, 2023

I could easily see this becoming deprecated or just unnecessary once we have a more comprehensive pattern for elements. IMO, this need means we need to design a way for elements to have an async trap before an update.

Towards that end...

Elements can schedule updates using scheduleUpdate and potentially do async work there, but it's not a convenient override point because it's not part of the controller lifecycle.

This is why I suggested update as the spot. If we go that way, we'd want to change performUpdate to:

  1. await the result of update if there is one.
  2. return that result so the update itself can wait

Or something like that.

Given that some design work seems warranted, I'm just a bit uncertain about pushing this through as is.

@justinfagnani
Copy link
Collaborator

I don't think we want to make performUpdate() async. That brings all kinds of questions of what to do with concurrent updates (namely, continue and enqueue new update vs abort), and we're actively trying to move away from async performUpdate() by adding scheduleUpdate() and deprecating returning a Promise from performUpdate().

The concern is about exposing the API at the right level where knowledge exists about what to do on the server. It seems like this makes more sense at the element level than the controller.

I think both need to be involved, because we want the system to work correctly when an element uses a controller that must do async work for initial render. Task may be configurable, but there are other abstractions that might say fetch initial data asynchronously and always want to force a wait.

I share the concern about this API being ready to commit to though. Even on a bike-shedding axis, I'm wary of adding too many "server" specific names. Is there some more general concept we're really getting at there, like "initial"*?

So I think we should hold off on merging this a bit. I'd like to review (sorry, been very busy lately!) and I think we should include in the same branch real usage of the API, like updating the real Task implementation to use it, to prove that it suits at least the current needs we have. (and maybe we could make this a private API that only Task has access to initial to gather feedback?)

I'd also like to think through if there's a version of this API where the element is more in charge (but we don't make performUpdate() async). This might look like having a new method on ReactiveControllerHost to wait for initial render until a Promise resolves. It's not a hugely different, API really, it just puts the additional responsibility on the host and reduces how much SSR needs to know about controllers.

@AndrewJakubowicz
Copy link
Contributor Author

Great discussion here! To mark that this PR is not ready to merge I'm downgrading this PR back to a draft.

Thank you Justin and Steve for your reviews!

One option that Peter suggested was to add a labs/server-task package which adds the SSR functionality to Task (without impacting the stable Task package). I'll branch off this branch to make the server-task easier to review.

@AndrewJakubowicz AndrewJakubowicz marked this pull request as draft November 22, 2023 22:04
@sorvell
Copy link
Member

sorvell commented Nov 26, 2023

I don't think we want to make performUpdate() async. That brings all kinds of questions of what to do with concurrent updates (namely, continue and enqueue new update vs abort)

IMO, we've already taken this on directly by having scheduleUpdate support asynchrony. There's already code to handle concurrent updates (that scheduleUpdate's response participates in): the next update is blocked on the current update completing.

AFAIR, we deprecated performUpdate's asynchrony specifically because we added scheduleUpdate.

@sorvell
Copy link
Member

sorvell commented Nov 26, 2023

To respect autoRun and the args function, I think ServerLoadedTask would need to do something like:

class ServerLoadedTask extends Task implements ServerController {
  get serverUpdateComplete() {
    this.hostUpdate();
    if (this.status === TaskStatus.PENDING) {
      return this.taskComplete;
    }
  }
}

@sorvell
Copy link
Member

sorvell commented Nov 26, 2023

This is maybe more about Task than the ServerController interface, but one thing that bugs me is that the easy way to use Task typically results in a 2x update even when the task really shouldn't run. Consider this slight modification of the playground example.

Here the package displayer is a separate element that takes the packageName as a property. It should only run the task if there's a packageName given. The code avoids the expensive fetch but does run the task and return the initial state, which provokes an additional update. While cheap, this isn't ideal and is probably worth avoiding, and if we do it on the server, that'll make the element render async, which again I think worth avoiding.

it's possible to finagle autoRun (commented in the example) to avoid this, but this requires involving the element, which would mean that it couldn't naively just use ServerLoadedTask.

@justinfagnani
Copy link
Collaborator

@sorvell

I don't think we want to make performUpdate() async. That brings all kinds of questions of what to do with concurrent updates (namely, continue and enqueue new update vs abort)

IMO, we've already taken this on directly by having scheduleUpdate support asynchrony. There's already code to handle concurrent updates (that scheduleUpdate's response participates in): the next update is blocked on the current update completing.

AFAIR, we deprecated performUpdate's asynchrony specifically because we added scheduleUpdate.

Yeah, async scheduleUpdate() is fine, but sync performUpdate() is very useful, and we've had custom discussions even this week around using that for some niche cases.

This is maybe more about Task than the ServerController interface, but one thing that bugs me is that the easy way to use Task typically results in a 2x update even when the task really shouldn't run.

This is a side effect of the whole task function-calling call tree being async functions. We could rewrite things a bit so that _performTask(), run(), and the user's task function are optionally async. That would allow returning initialState or any data, or throwing synchronously, which would play slightly nicer with SSR.

It's a small optimization though, saving one microtask per task that has the next state synchronously available.

I think Task that works with the element being in control of waiting would look something like this, btw:

  async run(args?: T) {
    //...
    this.status = TaskStatus.PENDING;
    let result!: R | typeof initialState;
    let error: unknown;

    // Request an update to report pending state.
    if (this.autoRun === 'afterUpdate') {
      // Avoids a change-in-update warning
      queueMicrotask(() => this._host.requestUpdate());
      
      // afterUpdate tasks don't make sense in SSR, right?
      if (isServer) {
        console.log('skipping afterUpdate task in SSR');
      }
    } else {
      // get the host to report current task status like PENDING
      this._host.requestUpdate();
      // Tell the host to update after the task is complete?
      this._host.requestDeferredUpdate(this.taskComplete);
      // note, accessing this.taskComplete unconditionally like this defeats the lazy Promise creation we added
    }
  }

@nickchomey
Copy link

Sorry to bother here, but is this something that is still planned to be implemented at some point? Or should we look for other solutions?

@kevinpschaaf kevinpschaaf mentioned this pull request Jan 21, 2025
1 task
@danielhjacobs
Copy link

Due to the use of the phrase "resolve #1434" in the PR description this PR is linked as fixing that issue, even though when reading the description fully it looks like that is not true.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[labs/ssr] Decide on strategy for handling data-mismatch during hydration between server and client

7 participants