Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tired-games-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@duskit/components": minor
---

feat(components): allow `interval` prop to accept a function for dynamic scaling in `Rerender`
118 changes: 107 additions & 11 deletions packages/components/src/__tests__/Rerender.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,15 @@ describe("Rerender", () => {
baseOptions
);

expect(container.innerHTML).toMatchInlineSnapshot(`"<!----><!---->0"`);
expect(container.textContent).toMatchInlineSnapshot(`"0"`);

await vi.advanceTimersByTimeAsync(1000);

expect(container.innerHTML).toMatchInlineSnapshot(`"<!----><!---->1"`);
expect(container.textContent).toMatchInlineSnapshot(`"1"`);

await vi.advanceTimersByTimeAsync(1000);

expect(container.innerHTML).toMatchInlineSnapshot(`"<!----><!---->2"`);
expect(container.textContent).toMatchInlineSnapshot(`"2"`);

expect(domMutations).toBe(2);
});
Expand All @@ -70,26 +70,122 @@ describe("Rerender", () => {
props,
});

expect(container.innerHTML).toMatchInlineSnapshot(`"<!----><!---->0"`);
expect(container.textContent).toMatchInlineSnapshot(`"0"`);

await vi.advanceTimersByTimeAsync(props.interval / 2);

expect(container.innerHTML).toMatchInlineSnapshot(`"<!----><!---->0"`);
expect(container.textContent).toMatchInlineSnapshot(`"0"`);

await vi.advanceTimersByTimeAsync(props.interval / 2);

expect(container.innerHTML).toMatchInlineSnapshot(`"<!----><!---->1"`);
expect(container.textContent).toMatchInlineSnapshot(`"1"`);

await vi.advanceTimersByTimeAsync(props.interval / 2);

expect(container.innerHTML).toMatchInlineSnapshot(`"<!----><!---->1"`);
expect(container.textContent).toMatchInlineSnapshot(`"1"`);

await vi.advanceTimersByTimeAsync(props.interval / 2);

expect(container.innerHTML).toMatchInlineSnapshot(`"<!----><!---->2"`);
expect(container.textContent).toMatchInlineSnapshot(`"2"`);
expect(domMutations).toBe(2);
});

it("should accept a function for the `interval` property and dynamically evaluate it to schedule the next update", async () => {
const intervalMock = vi
.fn()
.mockReturnValueOnce(1000)
.mockReturnValueOnce(2000)
.mockReturnValueOnce(3000);

const props = { interval: intervalMock };
const { container } = renderAndObserveContainer(RerenderCounter, {
...baseOptions,
props,
});

// Initial render
expect(container.textContent).toMatchInlineSnapshot(`"0"`);
expect(intervalMock).toHaveBeenCalledTimes(1);

// Advance to 1ms before the first tick
await vi.advanceTimersByTimeAsync(999);

expect(container.textContent).toMatchInlineSnapshot(`"0"`);

// Complete the first tick (1000ms delay)
await vi.advanceTimersByTimeAsync(1);

expect(container.textContent).toMatchInlineSnapshot(`"1"`);
expect(intervalMock).toHaveBeenCalledTimes(2);

// Advance to 1ms before the second tick
await vi.advanceTimersByTimeAsync(1999);

expect(container.textContent).toMatchInlineSnapshot(`"1"`);

// Complete the second tick (2000ms delay)
await vi.advanceTimersByTimeAsync(1);

expect(container.textContent).toMatchInlineSnapshot(`"2"`);
expect(intervalMock).toHaveBeenCalledTimes(3);

// Advance to 1ms before the third tick
await vi.advanceTimersByTimeAsync(2999);

expect(container.textContent).toMatchInlineSnapshot(`"2"`);

// Complete the third tick (3000ms delay)
await vi.advanceTimersByTimeAsync(1);

expect(container.textContent).toMatchInlineSnapshot(`"3"`);
expect(intervalMock).toHaveBeenCalledTimes(4);

// Verify exactly 3 mutations occurred
expect(domMutations).toBe(3);
});

it("should clear the previous timeout and schedule a new one when the `interval` function reference changes", async () => {
const intervalMock1 = vi.fn().mockReturnValue(1000);
const intervalMock2 = vi.fn().mockReturnValue(2000);

const { container, rerender } = renderAndObserveContainer(RerenderCounter, {
...baseOptions,
props: { interval: intervalMock1 },
});

// Initial render: schedules first tick at 1000ms
expect(container.textContent).toMatchInlineSnapshot(`"0"`);
expect(intervalMock1).toHaveBeenCalledTimes(1);

// Advance 500ms: halfway through the first interval
await vi.advanceTimersByTimeAsync(500);

expect(container.textContent).toMatchInlineSnapshot(`"0"`);
expect(domMutations).toBe(0);

// Rerender with a completely new function reference
await rerender({ interval: intervalMock2 });

// The reactive statement should have cleared the old timer
// and invoked the new function to schedule a fresh tick
expect(intervalMock2).toHaveBeenCalledTimes(1);
expect(intervalMock1).toHaveBeenCalledTimes(1);

// Advance 500ms. If the old 1000ms timer wasn't cleared, it would
// fire now (500 + 500 = 1000) causing an unwanted mutation.
await vi.advanceTimersByTimeAsync(500);

expect(container.textContent).toMatchInlineSnapshot(`"0"`);
expect(domMutations).toBe(0);

// Advance 1500ms more to complete the new 2000ms timer (500 + 1500 = 2000).
await vi.advanceTimersByTimeAsync(1500);

expect(container.textContent).toMatchInlineSnapshot(`"1"`);
expect(domMutations).toBe(1);
expect(intervalMock2).toHaveBeenCalledTimes(2);
});

it("should accept a custom `generateValue` function and use its result both as re-render key and as the default slot content", async () => {
const values = [1, 2];
const { container } = renderAndObserveContainer(RerenderGenerateValue1, {
Expand Down Expand Up @@ -218,7 +314,7 @@ describe("Rerender", () => {
// Advance to 500ms. The first timer is halfway done.
await vi.advanceTimersByTimeAsync(500);

expect(container.innerHTML).toMatchInlineSnapshot(`"<!----><!---->0"`);
expect(container.textContent).toMatchInlineSnapshot(`"0"`);
expect(domMutations).toBe(0);

// Change the interval to 2000ms. This should destroy the old 1000ms timer
Expand All @@ -229,13 +325,13 @@ describe("Rerender", () => {
// (500 + 500 = 1000) causing a mutation. We check that the DOM is untouched.
await vi.advanceTimersByTimeAsync(500);

expect(container.innerHTML).toMatchInlineSnapshot(`"<!----><!---->0"`);
expect(container.textContent).toMatchInlineSnapshot(`"0"`);
expect(domMutations).toBe(0);

// Advance 1500ms more to complete the new 2000ms timer (500 + 1500 = 2000).
await vi.advanceTimersByTimeAsync(1500);

expect(container.innerHTML).toMatchInlineSnapshot(`"<!----><!---->1"`);
expect(container.textContent).toMatchInlineSnapshot(`"1"`);
expect(domMutations).toBe(1);
});

Expand Down
15 changes: 5 additions & 10 deletions packages/components/src/relative-time/RelativeTime.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -25,20 +25,15 @@
export const getRootElement = () => rootElement;

/** @param {Date} d */
const getGenerator = (d) =>
function () {
interval = getInterval();
const getGenerator = (d) => () => getRelativeTimeString(d, "long");

return getRelativeTimeString(d, "long");
};

const getInterval = () =>
autoRefresh ? getRelativeTimeUnit(date.getTime() - Date.now()).factor : -1;

let interval = getInterval();
/** @param {Date} d */
const getInterval = (d) => () =>
getRelativeTimeUnit(d.getTime() - Date.now()).factor;

$: classes = makeClassName(["dusk-relative-time", className]);
$: generator = getGenerator(date);
$: interval = autoRefresh ? getInterval(date) : undefined;
</script>

<time
Expand Down
2 changes: 1 addition & 1 deletion packages/components/src/rerender/Rerender.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { SvelteComponent } from "svelte";

export interface RerenderProps<T = any> {
generateValue?: () => T;
interval?: number;
interval?: number | (() => number);
}

interface RerenderSlots<T> {
Expand Down
22 changes: 18 additions & 4 deletions packages/components/src/rerender/Rerender.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,24 @@
$: {
clearTimeout(timeoutId);

timeoutId = window.setTimeout(() => {
updateValueIfNeeded(generateValue);
updateFlag ^= 1;
}, interval);
timeoutId = window.setTimeout(
() => {
updateValueIfNeeded(generateValue);

/**
* We always mutate `updateFlag` to force Svelte to
* re-evaluate this reactive block.
* Since `setTimeout` only runs once, reading and
* writing this dependency creates a deliberate reactive
* infinite loop. This allows us to dynamically re-evaluate
* the `interval` function at every single tick, keeping the
* internal metronome alive without breaking reactivity when
* external prop references change.
*/
updateFlag ^= 1;
},
typeof interval === "function" ? interval() : interval
);
}
</script>

Expand Down
Loading