-
Notifications
You must be signed in to change notification settings - Fork 1k
[labs/ssr] Add ServerController interface allowing async work during SSR #4390
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
base: main
Are you sure you want to change the base?
Conversation
🦋 Changeset detectedLatest commit: c75f9a6 The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
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 |
📊 Tachometer Benchmark ResultsSummarynop-update
render
update
update-reflect
Resultsthis-change
render
update
update-reflect
this-change, tip-of-tree, previous-release
render
update
nop-update
this-change, tip-of-tree, previous-release
render
update
this-change, tip-of-tree, previous-release
render
update
update-reflect
|
|
The size of lit-html.js and lit-core.min.js are as expected. |
serverUpdateComplete promise to ReactiveController interface for SSR async work
augustjk
left a comment
There was a problem hiding this 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!
| --- | ||
| '@lit/reactive-element': patch | ||
| 'lit-element': patch | ||
| --- |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| --- | |
| '@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. |
There was a problem hiding this comment.
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.
| 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. |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| 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(); |
There was a problem hiding this comment.
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.
| 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) { |
There was a problem hiding this comment.
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.
| // To satisfy the ReactiveController interface. | ||
| declare hostConnected: undefined; |
There was a problem hiding this comment.
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 () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Very nice tests!
|
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);
} |
|
The intention for this feature is to provide a low level utility which can later be used by controllers (such as 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? |
|
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.
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... |
|
@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. |
rictic
left a comment
There was a problem hiding this 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); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| .filter((p: Promise<unknown> | undefined) => !!p); | |
| .filter((p: Promise<unknown> | undefined) => p != null); |
Prefer the more specific comparison
|
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 This is why I suggested
Or something like that. Given that some design work seems warranted, I'm just a bit uncertain about pushing this through as is. |
|
I don't think we want to make
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 |
|
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 |
IMO, we've already taken this on directly by having AFAIR, we deprecated |
|
To respect class ServerLoadedTask extends Task implements ServerController {
get serverUpdateComplete() {
this.hostUpdate();
if (this.status === TaskStatus.PENDING) {
return this.taskComplete;
}
}
} |
|
This is maybe more about Here the package displayer is a separate element that takes the it's possible to finagle |
Yeah, async
This is a side effect of the whole task function-calling call tree being async functions. We could rewrite things a bit so that 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
}
} |
|
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? |
|
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. |
Part of issue: #2469
Change split out of and adapted from #1690
Context
Add a new interface
ServerController:Any ReactiveController that implements this interface will cause SSR to yield and
await serverUpdateComplete.This unlocks some new async SSR patterns.
Design
LitElementRenderernow pulls the list of ReactiveControllers of the LitElement it is about to SSR, and then awaits all controllers implementingServerControllerbefore rendering the contents of the element.In order to access the ReactiveControllers on a LitElement a new
getControllersfunction has been exposed via_$LE. A LitElement's private set of controllers is now bound to the stable minified identifierW.Unlocks the following new SSR use cases:
@lit/taskcan be extended to run during SSR. Allowing the SSR'd content to contain the completed Task data.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/taskwith SSR, which can be tested withnpm run demo:globalfrom the/labs/ssrdirectory.Test plan
Added SSR tests that highlight
ReactiveControllerandServerControllerbasic 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:
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