Skip to content

Commit f675495

Browse files
authored
Merge pull request #6 from hydrostack/5-polling
5 polling
2 parents b3aafb9 + 925a3e3 commit f675495

File tree

7 files changed

+150
-47
lines changed

7 files changed

+150
-47
lines changed

docs/content/.vitepress/config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export default defineConfig({
3232
{ text: 'Navigation', link: '/features/navigation' },
3333
{ text: 'Authorization', link: '/features/authorization' },
3434
{ text: 'Form validation', link: '/features/form-validation' },
35+
{ text: 'Long polling', link: '/features/long-polling' },
3536
{ text: 'Errors handling', link: '/features/errors-handling' },
3637
{ text: 'Anti-forgery token', link: '/features/xsrf-token' },
3738
{ text: 'User interface utilities', link: '/features/ui-utils' },
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# Long polling
6+
7+
Hydro provides a feature to poll the component's action at regular intervals. It is useful when you want to make sure that in one of the places on the page you're displaying the most recent data.
8+
9+
To enable long polling on an action, decorate it with `[Poll]` attribute. Default interval is set to 3 seconds (`3_000` milliseconds). To customize it, change the `Interval` property to the desired value in milliseconds. Actions decorated with `[Poll]` attribute have to be parameterless.
10+
11+
In the example below the `Refresh` action will be called every 60 seconds:
12+
13+
```csharp
14+
public class NotificationsIndicator(INotifications notifications) : HydroComponent
15+
{
16+
public int NotificationsCount { get; set; }
17+
18+
[Poll(Interval = 60_000)]
19+
public async Task Refresh()
20+
{
21+
NotificationsCount = await notifications.GetCount();
22+
}
23+
}
24+
```
25+
26+
```razor
27+
@model NotificationsIndicator
28+
29+
<div>
30+
Notifications: <strong>@Model.NotificationsCount</strong>
31+
</div>
32+
```
33+
34+
## Polling pauses
35+
36+
When a page with a polling component is hidden (not in the currently open tab), polling will stop and restart once the tab becomes visible again.

src/HydroComponent.cs

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ public abstract class HydroComponent : ViewComponent
2828
private readonly ConcurrentDictionary<CacheKey, object> _requestCache = new();
2929
private static readonly ConcurrentDictionary<CacheKey, object> PersistentCache = new();
3030

31+
private static readonly ConcurrentDictionary<Type, List<HydroPoll>> Polls = new();
32+
3133
private readonly List<HydroComponentEvent> _dispatchEvents = new();
3234
private readonly HashSet<HydroEventSubscription> _subscriptions = new();
3335

@@ -72,6 +74,47 @@ public abstract class HydroComponent : ViewComponent
7274
public HydroComponent()
7375
{
7476
Subscribe<HydroBind>(data => SetPropertyValue(data.Name, data.Value));
77+
78+
ConfigurePolls();
79+
}
80+
81+
private void ConfigurePolls()
82+
{
83+
var componentType = GetType();
84+
85+
if (Polls.ContainsKey(componentType))
86+
{
87+
return;
88+
}
89+
90+
var methods = componentType
91+
.GetMethods(BindingFlags.Public | BindingFlags.Instance)
92+
.Where(m => m.DeclaringType != typeof(HydroComponent))
93+
.Select(m => (Method: m, Attribute: m.GetCustomAttribute<PollAttribute>()))
94+
.Where(m => m.Attribute != null)
95+
.Select(m => (m.Method, m.Attribute, ParametersCount: m.Method.GetParameters().Length))
96+
.ToList();
97+
98+
if (methods.Any(p => p.Method.GetBaseDefinition() != p.Method))
99+
{
100+
throw new InvalidOperationException("Poll can be defined only on custom actions");
101+
}
102+
103+
if (methods.Any(p => p.ParametersCount != 0))
104+
{
105+
throw new InvalidOperationException("Poll can be defined only on actions without parameters");
106+
}
107+
108+
if (methods.Any(p => p.Attribute.Interval <= 0))
109+
{
110+
throw new InvalidOperationException("Polling's interval is invalid");
111+
}
112+
113+
var polls = methods
114+
.Select(p => new HydroPoll(p.Method.Name, TimeSpan.FromMilliseconds(p.Attribute.Interval)))
115+
.ToList();
116+
117+
Polls.TryAdd(componentType, polls);
75118
}
76119

77120
/// <summary>
@@ -193,7 +236,7 @@ public virtual Task RenderAsync()
193236
Render();
194237
return Task.CompletedTask;
195238
}
196-
239+
197240
/// <summary>
198241
/// Triggered before each render
199242
/// </summary>
@@ -368,6 +411,14 @@ private async Task<string> GenerateComponentHtml(string componentId, IPersistent
368411
var hydroAttribute = rootElement.SetAttributeValue("hydro", null);
369412
hydroAttribute.QuoteType = AttributeValueQuote.WithoutValue;
370413

414+
if (Polls.TryGetValue(GetType(), out var polls))
415+
{
416+
for (var i = 0; i < polls.Count; i++)
417+
{
418+
rootElement.AppendChild(GetPollScript(componentHtmlDocument, polls[i], i));
419+
}
420+
}
421+
371422
rootElement.AppendChild(GetModelScript(componentHtmlDocument, componentId, persistentState));
372423

373424
foreach (var subscription in _subscriptions)
@@ -475,6 +526,14 @@ private HtmlNode GetEventSubscriptionScript(HtmlDocument document, HydroEventSub
475526
scriptNode.SetAttributeValue("x-on-hydro-event", JsonConvert.SerializeObject(eventData));
476527
return scriptNode;
477528
}
529+
530+
private HtmlNode GetPollScript(HtmlDocument document, HydroPoll poll, int index)
531+
{
532+
var scriptNode = document.CreateElement("script");
533+
scriptNode.SetAttributeValue($"x-hydro-polling.{poll.Interval.TotalMilliseconds}ms._{index}", poll.Action);
534+
scriptNode.SetAttributeValue("type", "text/hydro");
535+
return scriptNode;
536+
}
478537

479538
private void PopulateDispatchers()
480539
{

src/HydroPoll.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
namespace Hydro;
2+
3+
internal record HydroPoll(string Action, TimeSpan Interval);

src/PollAttribute.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
namespace Hydro;
2+
3+
/// <summary>
4+
/// Enable long polling for action decorated with this attribute
5+
/// </summary>
6+
[AttributeUsage(AttributeTargets.Method)]
7+
public class PollAttribute : Attribute
8+
{
9+
/// <summary>
10+
/// How often action will be called. In milliseconds.
11+
/// </summary>
12+
public int Interval { get; set; } = 3_000;
13+
}

src/Scripts/hydro.js

Lines changed: 36 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -428,50 +428,6 @@
428428
window.Hydro = new HydroCore();
429429

430430
document.addEventListener('alpine:init', () => {
431-
Alpine.directive('hydro-action', (el, { expression }, { effect, cleanup }) => {
432-
effect(() => {
433-
const customEvent = el.getAttribute('hydro-event');
434-
const eventName = customEvent || (el.tagName === 'FORM' ? 'submit' : 'click');
435-
const delay = el.getAttribute('hydro-delay');
436-
const autorun = el.getAttribute('hydro-autorun');
437-
438-
if (autorun) {
439-
setTimeout(() => window.Hydro.hydroAction(el), delay || 0)
440-
} else {
441-
const eventHandler = async (event) => {
442-
event.preventDefault();
443-
if (el.disabled) {
444-
return;
445-
}
446-
447-
setTimeout(() => window.Hydro.hydroAction(el, eventName), delay || 0)
448-
};
449-
el.addEventListener(eventName, eventHandler);
450-
cleanup(() => {
451-
el.removeEventListener(eventName, eventHandler);
452-
});
453-
}
454-
});
455-
});
456-
457-
Alpine.directive('hydro-on', (el, { value, expression }, { effect, cleanup }) => {
458-
effect(() => {
459-
const eventHandler = async (event) => {
460-
event.preventDefault();
461-
if (el.disabled) {
462-
return;
463-
}
464-
465-
setTimeout(() => window.Hydro.hydroAction(el, value, expression), 200)
466-
};
467-
468-
el.addEventListener(value, eventHandler);
469-
cleanup(() => {
470-
el.removeEventListener(value, eventHandler);
471-
});
472-
});
473-
});
474-
475431
Alpine.directive('hydro-dispatch', (el, { expression }, { effect, cleanup }) => {
476432
effect(() => {
477433
if (!document.contains(el)) {
@@ -553,6 +509,42 @@ document.addEventListener('alpine:init', () => {
553509
});
554510
});
555511
}).before('on');
512+
513+
Alpine.directive('hydro-polling', Alpine.skipDuringClone((el, { value, expression, modifiers }, { effect, cleanup }) => {
514+
let isQueued = false;
515+
let interval;
516+
const component = window.Hydro.findComponent(el);
517+
const time = parseInt(modifiers[0].replace('ms', ''));
518+
519+
const setupInterval = () => {
520+
interval = setInterval(async () => {
521+
if (document.hidden) {
522+
isQueued = true;
523+
clearInterval(interval);
524+
return;
525+
}
526+
527+
await window.Hydro.hydroAction(el, component, { name: expression }, null);
528+
}, time);
529+
}
530+
531+
const handleVisibilityChange = async () => {
532+
if (!document.hidden && isQueued) {
533+
isQueued = false;
534+
await window.Hydro.hydroAction(el, component, { name: expression });
535+
setupInterval();
536+
}
537+
}
538+
539+
document.addEventListener('visibilitychange', handleVisibilityChange);
540+
setupInterval();
541+
542+
cleanup(() => {
543+
document.removeEventListener('visibilitychange', handleVisibilityChange);
544+
clearInterval(interval);
545+
});
546+
547+
}));
556548

557549
Alpine.directive('on-hydro-event', (el, { expression }, { effect, cleanup }) => {
558550
effect(() => {
@@ -581,7 +573,6 @@ document.addEventListener('alpine:init', () => {
581573
});
582574
});
583575

584-
585576
let currentBoostUrl;
586577

587578
Alpine.directive('hydro-link', (el, { expression }, { effect, cleanup }) => {

src/TagHelpers/HydroActionTagHelper.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using Microsoft.AspNetCore.Html;
1+
using Microsoft.AspNetCore.Html;
22
using Microsoft.AspNetCore.Mvc.Rendering;
33
using Microsoft.AspNetCore.Mvc.ViewFeatures;
44
using Microsoft.AspNetCore.Razor.TagHelpers;

0 commit comments

Comments
 (0)