Skip to content

Commit 6dfe76f

Browse files
committed
feat: accept DurationLike values and use assertions to avoid rendering an invalid component
1 parent 23b74ba commit 6dfe76f

File tree

5 files changed

+81
-58
lines changed

5 files changed

+81
-58
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2+
3+
exports[`ObsidianMarkdown > should throw an error when 'props.delay' is invalid 1`] = `[Error: Unknown duration argument one hour of type string]`;
Original file line numberDiff line numberDiff line change
@@ -1,88 +1,92 @@
11
import { render } from "@testing-library/preact";
2-
import { Duration } from "luxon";
2+
import { DurationLike } from "luxon";
3+
import { OmitByValue } from "utility-types";
34
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
45

56
import { App, Component, MarkdownRenderer } from "@/lib/obsidian/types";
67

7-
import { ObsidianMarkdown } from "../obsidian";
8+
import { ObsidianMarkdown, ObsidianMarkdownProps } from "../obsidian";
89

910
vi.mock("@/lib/obsidian/types");
1011
afterEach(() => vi.restoreAllMocks());
1112

1213
describe(`${ObsidianMarkdown.name}`, () => {
14+
const required: OmitByValue<ObsidianMarkdownProps, undefined> = {
15+
app: new App(),
16+
component: new Component(),
17+
sourcePath: "/vault/diary.md",
18+
markdown: "Foo",
19+
};
20+
1321
beforeAll(() => vi.useFakeTimers());
1422
afterAll(() => vi.useRealTimers());
1523

16-
it("should rerender when markdown changes stay stable for a while", async () => {
17-
const props = {
18-
app: new App(),
19-
component: new Component(),
20-
sourcePath: "/vault/diary.md",
21-
delay: Duration.fromMillis(500),
22-
} as const;
24+
it("should render eventually", async () => {
25+
render(<ObsidianMarkdown {...required} />);
26+
expect(MarkdownRenderer.render).not.toHaveBeenCalled();
27+
await vi.runAllTimersAsync();
28+
expect(MarkdownRenderer.render).toHaveBeenCalledTimes(1);
29+
});
2330

24-
const { rerender } = render(<ObsidianMarkdown {...props} markdown="Apple" />);
31+
it("should throw an error when 'props.delay' is invalid", () => {
32+
const invalid = "one hour" as DurationLike;
33+
expect(() => render(<ObsidianMarkdown {...required} delay={invalid} />)).toThrowErrorMatchingSnapshot();
34+
});
35+
36+
it("should rerender when 'props.markdown' stops changing for a while", async () => {
37+
const { rerender } = render(<ObsidianMarkdown {...required} delay={500} markdown="Apple" />);
2538
await vi.runAllTimersAsync();
26-
rerender(<ObsidianMarkdown {...props} markdown="Banana" />);
39+
rerender(<ObsidianMarkdown {...required} delay={500} markdown="Banana" />);
2740
await vi.advanceTimersByTimeAsync(300);
28-
rerender(<ObsidianMarkdown {...props} markdown="Banana" />);
41+
rerender(<ObsidianMarkdown {...required} delay={500} markdown="Banana" />);
2942
await vi.advanceTimersByTimeAsync(300);
3043

31-
const anySpanElement = expect.any(HTMLSpanElement);
3244
expect(MarkdownRenderer.render).toHaveBeenCalledTimes(2);
3345
expect(MarkdownRenderer.render).toHaveBeenCalledWith(
34-
props.app,
46+
required.app,
3547
"Apple",
36-
anySpanElement,
37-
props.sourcePath,
38-
props.component,
48+
expect.any(HTMLSpanElement),
49+
required.sourcePath,
50+
required.component,
3951
);
4052
expect(MarkdownRenderer.render).toHaveBeenCalledWith(
41-
props.app,
53+
required.app,
4254
"Banana",
43-
anySpanElement,
44-
props.sourcePath,
45-
props.component,
55+
expect.any(HTMLSpanElement),
56+
required.sourcePath,
57+
required.component,
4658
);
4759
});
4860

49-
it("should not rerender when markdown changes too frequently", async () => {
50-
const props = {
51-
app: new App(),
52-
component: new Component(),
53-
sourcePath: "/vault/diary.md",
54-
delay: Duration.fromMillis(500),
55-
} as const;
56-
57-
const { rerender } = render(<ObsidianMarkdown {...props} markdown="Apple" />);
61+
it("should not rerender when 'props.markdown' keeps changing", async () => {
62+
const { rerender } = render(<ObsidianMarkdown {...required} delay={500} markdown="Apple" />);
5863
await vi.runAllTimersAsync();
59-
rerender(<ObsidianMarkdown {...props} markdown="Banana" />);
64+
rerender(<ObsidianMarkdown {...required} delay={500} markdown="Banana" />);
6065
await vi.advanceTimersByTimeAsync(300);
61-
rerender(<ObsidianMarkdown {...props} markdown="Cherry" />);
66+
rerender(<ObsidianMarkdown {...required} delay={500} markdown="Cherry" />);
6267
await vi.advanceTimersByTimeAsync(300);
6368

64-
const anySpanElement = expect.any(HTMLSpanElement);
6569
expect(MarkdownRenderer.render).toHaveBeenCalledTimes(1);
6670
expect(MarkdownRenderer.render).toHaveBeenCalledWith(
67-
props.app,
71+
required.app,
6872
"Apple",
69-
anySpanElement,
70-
props.sourcePath,
71-
props.component,
73+
expect.any(HTMLSpanElement),
74+
required.sourcePath,
75+
required.component,
7276
);
7377
expect(MarkdownRenderer.render).not.toHaveBeenCalledWith(
74-
props.app,
78+
required.app,
7579
"Banana",
76-
anySpanElement,
77-
props.sourcePath,
78-
props.component,
80+
expect.any(HTMLSpanElement),
81+
required.sourcePath,
82+
required.component,
7983
);
8084
expect(MarkdownRenderer.render).not.toHaveBeenCalledWith(
81-
props.app,
85+
required.app,
8286
"Cherry",
83-
anySpanElement,
84-
props.sourcePath,
85-
props.component,
87+
expect.any(HTMLSpanElement),
88+
required.sourcePath,
89+
required.component,
8690
);
8791
});
8892
});
+1-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
import { Duration } from "luxon";
22

3-
export const MARKDOWN_RENDER_DEBOUNCE_TIME = Duration.fromDurationLike({ milliseconds: 500 });
3+
export const MARKDOWN_RENDER_DEBOUNCE_TIME = Duration.fromMillis(500);

src/render/preact/lib/obsidian.tsx

+11-12
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,29 @@
1-
import { Duration } from "luxon";
1+
import { Duration, DurationLike } from "luxon";
22
import { createElement, FunctionalComponent, JSX } from "preact";
3-
import { useEffect, useRef } from "preact/hooks";
3+
import { useEffect, useMemo, useRef } from "preact/hooks";
44
import { useDebounceCallback } from "usehooks-ts";
55

66
import { App, Component, MarkdownRenderer } from "@/lib/obsidian/types";
77

88
import { MARKDOWN_RENDER_DEBOUNCE_TIME } from "./obsidian.const";
99

1010
/**
11-
* Asynchronously renders the given markdown source code as Obsidian (the app) would.
12-
* Debounces values so that, ideally, renders have enough time to finish before being discarded for newer values.
13-
* @param props - configures how the markdown is rendered.
14-
* @returns component for asynchronously rendering the given markdown source code as Obsidian (the app) would.
11+
* @param props - props for the component.
12+
* @returns component that asynchronously renders the given markdown source code just as Obsidian (the app) would.
1513
*/
1614
export const ObsidianMarkdown: FunctionalComponent<ObsidianMarkdownProps> = (props) => {
1715
const { app, component, markdown, sourcePath, tagName = "span", delay = MARKDOWN_RENDER_DEBOUNCE_TIME } = props;
16+
const delayMs = useMemo(() => Duration.fromDurationLike(delay).toMillis(), [delay]);
17+
const renderObsidianMarkdown = useDebounceCallback(MarkdownRenderer.render, delayMs);
1818
const elRef = useRef<HTMLElement>();
19-
const render = useDebounceCallback(MarkdownRenderer.render, delay.toMillis());
2019

2120
useEffect(() => {
22-
const { current: el } = elRef;
21+
const el = elRef.current;
2322
if (el) {
24-
render(app, markdown, el, sourcePath, component);
25-
return render.cancel;
23+
renderObsidianMarkdown(app, markdown, el, sourcePath, component);
24+
return renderObsidianMarkdown.cancel;
2625
}
27-
}, [render, app, markdown, sourcePath, component]);
26+
}, [renderObsidianMarkdown, app, markdown, sourcePath, component]);
2827

2928
return createElement(tagName, { ref: elRef });
3029
};
@@ -42,5 +41,5 @@ export interface ObsidianMarkdownProps {
4241
/** The tag used to contain the rendered Markdown source code. */
4342
tagName?: keyof JSX.IntrinsicElements;
4443
/** Custom debounce time for renders. */
45-
delay?: Duration<true>;
44+
delay?: DurationLike;
4645
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2+
3+
exports[`useDuration > with invalid input={"hours":"?"} > should throw invalid duration error with opts=true 1`] = `[Error: Invalid unit value ?]`;
4+
5+
exports[`useDuration > with invalid input={"hours":"?"} > should throw invalid duration error with opts=true 2`] = `[Error: Invalid unit value ?]`;
6+
7+
exports[`useDuration > with invalid input={"hours":"one"} > should throw invalid duration error with opts=true 1`] = `[Error: Invalid unit value one]`;
8+
9+
exports[`useDuration > with invalid input={"hours":"one"} > should throw invalid duration error with opts=true 2`] = `[Error: Invalid unit value one]`;
10+
11+
exports[`useDuration > with invalid input={"hours":{"one":1}} > should throw invalid duration error with opts=true 1`] = `[Error: Invalid unit value [object Object]]`;
12+
13+
exports[`useDuration > with invalid input={"hours":{"one":1}} > should throw invalid duration error with opts=true 2`] = `[Error: Invalid unit value [object Object]]`;
14+
15+
exports[`useDuration > with invalid input=null > should throw invalid duration error with opts=true 1`] = `[Error: uh-oh!. null]`;
16+
17+
exports[`useDuration > with invalid input=null > should throw invalid duration error with opts=true 2`] = `[Error: uh-oh!. null]`;

0 commit comments

Comments
 (0)