-
Notifications
You must be signed in to change notification settings - Fork 10.1k
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
[Blazor] APIs to preserve content between enhanced navigations #50437
Conversation
2434163
to
fe4021c
Compare
API design question: rather than require people to use |
That's great, but seems like you'd also need a way to unregister. To make this feel even more natural, what do you think about copying the DOM event APIs, e.g., Blazor.addEventListener('enhancedload', myFunction);
Blazor.removeEventListener('enhancedload', myFunction); I know this opens the question of "what other events could I listen for". Whether that's a good or bad thing depends on the likelihood of us adding other listenable events in the future. |
@@ -33,6 +33,7 @@ interface IBlazor { | |||
navigateTo: (uri: string, options: NavigationOptions) => void; | |||
registerCustomEventType: (eventName: string, options: EventTypeOptions) => void; | |||
|
|||
registerEnhancedPageUpdateCallback?: (callback: () => void) => { dispose(): void }; |
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.
We can scope this out to enhanced navigation, but we might also make it more general and have this callback happen after every render update gets applied (enhanced navigation or interactive).
The people that do this through enhanced navigation is going to fall off a cliff the moment they make their component interactive, as the same approach won't work in that case.
I agree with @SteveSandersonMS on the comment about the id. If we were to use the id attribute, then we shouldn't need to use the data-permanence attribute, but I acknowledge there might be reasons why equating the two is not necessarily useful or desirable (not just because you give something and id you want it preserved, but there are also many cases where that's likely is the case). So, I would say either we ignore the id and use data-permanence="id" or just use the id, and given the unknowns I am more inclined to go with @SteveSandersonMS recommendation here. |
Seems fair. What other things we think we might add in the future here? |
That's interesting. We haven't really spelled out the scenarios and the reasons why someone would want to react on the JS side when each of the following occurs, and whether they have to be able to differentiate these occurrences:
I'd be keen to avoid adding lots of extensibility for everything without having a definite rationale and customer demand, as it all restricts us in the future. What's the smallest set of these we could limit it to, based on the known scenarios? |
Sounds good. On naming, I'd lean towards |
Is there a reason not to do this? It will require people to have two code paths, one for the first initialization and another one after an update happens. Yes, they can factor the code for max reuse, but it seems like extra work that could be avoided? |
I think what it comes down to is that we need to have a crisp understanding of how this works when a component becomes interactive. If developers need to reach out for a different mechanism in those situations, I believe we are failing to solve the issue effectively. |
Can you clarify more about what you mean? I'm unsure why "component becomes interactive" is central to the problem, since the problem also occurs even without any interactive components. |
If the API is called once enhanced navigation happens and is used to re-add attributes to an element dynamically, then if in a different circumstance the component is rendered as webassembly/server, that callback won't be triggered when they update the DOM, and the attributes won't necessarily be preserved. So at that point, the dev would need to use intersection observer or similar to re-attach the attributes. |
It's absolutely not supported to mutate interactively-rendered DOM from JS, so I'd definitely not do anything to facilitate that. |
I like that a lot! That does seem cleaner than relying on a separate attribute with other meanings.
There actually is a way to unregister, but I forgot to mention it in my PR comment. You can use the value returned by const registration = Blazor.registerEnhancedPageUpdateCallback(callback);
// ...
registration.dispose(); However, I do like your suggestion. It feels more JavaScriptey, and it helps enforce a level of consistency for Blazor events we might add in the future. Taking it further, what if we added custom events on document.addEventListener('blazorenhancedload', myFunction);
document.removeEventListener('blazorenhancedload', myFunction); This has the advantage that you don't need to wait until the
The main scenario I can think of is applying mutations to the DOM that just got erased by an enhanced update. Assuming we want to support applications mutating any part of the DOM (excluding interactive content), then I believe it follows that any enhanced update has potential to step over a DOM mutation made by the application. The conclusion I reach from there is that each type of enhanced content update should invoke the If customers only want to listen to a subset of those scenarios, we could potentially extend this by adding a
I did it this way purely because the initial page load is not an enhanced load. So if the customer really wants to listen only to "enhanced" updates, that initial call might be misleading. If we go with the approach where we add a custom event on document.addEventListener('blazorenhancedload', myFunction);
document.addEventListener('DOMContentLoaded', myFunction); Does that seem okay? |
This will break the second there are two Blazor instances on the page. |
I was thinking this would be fine since the main use case is adding content back to the page if it got removed (no matter "who" removed it). But I can see the argument for this being problematic if we want to add "Blazor instance specific" events. |
I'd rather we avoid adding more global state to the page, as it makes that scenario harder in the future. |
I've just updated the API so the usage is now: Blazor.addEventListener('enhancedload', myFunction);
Blazor.removeEventListener('enhancedload', myFunction); The argument to the registered listener is an object with the following structure: {
blazor: typeof Blazor,
type: 'enhancedload',
} The If the primary purpose of the If we anticipate that's a distinction that needs to be made, we could modify the event listener's parameter to have the following shape: {
blazor: typeof Blazor,
type: 'enhancedload',
kind: 'navigationstart' | 'navigationend' | 'update'
} ...where:
Thoughts about this? |
In terms of how we communicate/document this, that sounds slightly misleading because we try to preserve all DOM elements (on a best-effort basis), not just these ones. Am I right to say a more accurate phrasing would be: Elements with this attribute will not be synchronized during enhanced page updates. These elements and their subtrees will be left entirely unchanged, no matter how the corresponding new element differs. However, the whole element will be removed if there is no corresponding element in the new content. On reading the algorithm, I see that |
setTimeout(() => { | ||
isDispatchPending = false; | ||
Blazor._internal.dispatchEvent('enhancedload', {}); | ||
}, 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.
setTimeout(..., 0)
always sets off alarms since it means the caller loses the ability to do anything synchronous or even run any logic after this callback, without reverting to even more problematic tricks like setTimeout(..., 1)
and so on.
Not saying it's necessarily wrong since maybe this really is the only/cleanest way to achieve the goal. But could you clarify why SSR will do things multiple times synchronously, and whether we could instead make SSR responsible for only triggering this once at the end of its process?
If this comes down to "a single chunk of HTTP traffic may include multiple <blazor-ssr>
blocks" then sure that's true, but they may arrive arbitrarily quickly anyway, so it might not be particularly useful to distinguish "those that arrive synchronously together" from those who don't. What difference would it make if we fired the event for each one, whether or not there are others in the same synchronous block? The recipient of the event can still debounce if it needs to for its own reasons.
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.
Yeah, I think this is something that the server should tell the JS code, rather than relying on heuristics. Don't we insert a frame at the end of the response?
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.
Not saying it's necessarily wrong since maybe this really is the only/cleanest way to achieve the goal. But could you clarify why SSR will do things multiple times synchronously, and whether we could instead make SSR responsible for only triggering this once at the end of its process?
We currently invoke this callback:
- After the initial page load
- After the initial content is received during enhanced navigation
- After the document completes a streaming response update
- After the enhanced navigation completes
- This might seem redundant, but it enables reacting to "quiescence". Blazor itself uses this so that new components get activated only when enhanced navigation completes.
Given that the public event ('enhancedload'
) doesn't distinguish between these four cases, the setTimeout()
mechanism was used to prevent, for example, the callback being invoked after receiving initial enhanced navigation content, then immediately again if there's no additional streaming content.
But I do agree that the recipient can debounce these events if it needs to, and not having the setTimeout()
also allows us to extend this in the future if we expose which of the four cases resulted in the callback getting invoked (maybe someone wants to listen explicitly for the "navigation end" callback, and deduplicating this event results in that callback getting shadowed by the "initial load" callback).
Edit: So the approach I plan on taking is just removing the setTimeout()
and accepting the fact that there might be "duplicate" dispatches. Does that sound good?
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.
I've actually just updated this so the fourth case ("after the enhanced navigation completes") does not dispatch the 'enhancedload'
event, because it doesn't signal that changes to the DOM were made in that case. In the future, we could add a navigationend
event to allow developers to react to that case.
src/Components/Web.JS/src/Rendering/DomMerging/DataPermanentElementSync.ts
Outdated
Show resolved
Hide resolved
interface BlazorEvent { | ||
blazor: IBlazor; | ||
type: keyof BlazorEventMap; | ||
} |
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 is interesting. If I'm reading correctly, your use of blazor
here is for forward compatibility with some possible future where we pass the Blazor instance to the callback?
Presumably this is to handle the multiple-Blazor case.
But if so, wouldn't that already be covered by having a different event registration call for each Blazor instance? That would be more compatible, since the different Blazor instances could be of different versions and hence have completely different APIs. Or is there a different reason for doing this?
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.
If it's for internal use only (and I see you used Omit
below, perhaps with that in mind) then it would certainly be fine either way.
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.
I agree, if this is for an event, I don't see why we would have blazor
as an arg, given that you know where you are registering.
The other bit (which I am a bit unsure is whether this should have a similar shape as a https://developer.mozilla.org/en-US/docs/Web/API/Event
and have some of it's behaviors, like this
being associated with the event in the handler, and so on.
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 is interesting. If I'm reading correctly, your use of blazor here is for forward compatibility with some possible future where we pass the Blazor instance to the callback?
Yeah, that's correct. I added initially added this when taking the document.addEventListener
approach (instead of the current approach, which is Blazor.addEventListener
), so that developers could detect which Blazor
instance emitted the event. But since the event is now getting registered on the Blazor
instance itself, it's probably fine that we remove this.
The other bit (which I am a bit unsure) is whether this should have a similar shape as a https://developer.mozilla.org/en-US/docs/Web/API/Event
Couldn't we extend this API in the future in a non-breaking manner to emulate the shape of DOM events a bit more? Or is this something you think we need to decide on now?
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.
Couldn't we extend this API in the future in a non-breaking manner to emulate the shape of DOM events a bit more? Or is this something you think we need to decide on now?
Sure, I'd be fine with treating only the declared API as supported, and undocumented things like "the value of this
" being open for change in the future.
return true; | ||
} | ||
|
||
if (isDataPermanentA && elementA.id !== elementB.id) { |
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.
I thought we were changing the rule not to use id
, but instead use the values of the data-permanent
attributes to control this.
I'd also be happy with some rule like:
- If
data-permanent
is present:- If its value is an empty-string (like it is for valueless attributes, e.g.,
<div data-permanent>
, bearing in mind that the valueless is supported fordata-enhance
)- If the element has an ID, use that as the data permanence value
- Else use the fixed string
""
as the data permanence value, being sure not to treat it as false
- Else use the attribute value as the data permanence value
- If its value is an empty-string (like it is for valueless attributes, e.g.,
TBH I'd also be happy with not using element.id
at all, since it's yet another avoidable thing to document and support forever. This is based on the diff algorithm not matching on a global basis, and so it generally is going to behave as desired in many cases if you simply have data-permanent
with no attribute value.
What do you think?
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.
I think relying on the data-permanent
attribute value and nothing else (ignoring element.id
) sounds good. I've just updated the PR accordingly.
Good point, thanks for raising it! I would strongly lean towards not taking guesses about what people might want until we know, and hence not add these other kinds. For forward compatibility, there are two ways we could go:
I think option 2 is easier to justify but if you're really keen on option 1 that's a possibility. |
I'm more inclined to go with option 2, that's more in line with how events work on the DOM. The only one that I believe does something like 1 is the mutation observer API. |
b7bdce4
to
f0406f3
Compare
Thanks for all the feedback, @SteveSandersonMS and @javiercn! I plan to finish addressing your feedback tomorrow. |
That sounds good to me. I've updated the implementation to only dispatch the |
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.
Looks flawless.
Somehow we have to track the need to document Blazor.addEventListener
and the enhancedload
event. Would you be able to file a doc issue? You could either provide a summary of the functionality for @guardrex to work from, or not (and he will likely pursue you for the details later).
c82fa09
to
053550e
Compare
Depends on dotnet/aspnetcore#50437. Addresses dotnet/aspnetcore#49144. | Scenario | Validated | |---|---| | Statically server rendered page | ✅ | | Interactive server rendered page | ✅ | | Page with stream rendering enabled | ✅ | | Interactive client rendered page | ❌ (dotnet/aspnetcore#50765) | | Statically server rendered page with interactive components (all render modes) | ✅ |
@MackinnonBuck, this change will be considered for inclusion in the blog post for the release it'll ship in. Nice work! Please ensure that the original comment in this thread contains a clear explanation of what the change does, why it's important (what problem does it solve?), and, if relevant, include things like code samples and/or performance numbers. This content may not be exactly what goes into the blog post, but it will help the team putting together the announcement. Thanks! |
APIs to preserve content between enhanced navigations
Adds APIs to enable the preservation of DOM content between enhanced navigations.
Description
This PR introduces two new ways to preserve content between enhanced navigations:
data-permanent
HTML attributeid
attribute on an element marked asdata-permanent
will be used to match up data permanent elements when performing DOM synchronization.data-permanent
element. Therefore, thedata-permanent
element itself should not be added dynamically via JS, (but its content can be, which is the primary use case).Blazor.registerEnhancedPageUpdateCallback(callback)
data-permanent
attribute is not sufficient. It lets you get notified when an enhanced page update occurs so changes to the DOM can be made in response.Fixes #49613