Skip to content

Commit 83cec13

Browse files
committed
feat: support custom rehydratable query selectors
1 parent 0f13d96 commit 83cec13

File tree

5 files changed

+86
-22
lines changed

5 files changed

+86
-22
lines changed

src/ILoadedOptions.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
interface IOptions {
2+
allSelectors: { [key: string]: string };
3+
compoundSelector: string;
4+
extra: object;
5+
}
6+
7+
export default IOptions;

src/IOptions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
interface IOptions {
22
extra: object;
3+
getQuerySelector?: (key: string) => string;
34
}
45

56
export default IOptions;

src/__tests__/__snapshots__/tests.ts.snap

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
exports[`reactFromHtml E2E tests Should rehydrate a basic component 1`] = `"<div class=\\"rehydration-root\\"><span>rehydrated component</span></div>"`;
44
5+
exports[`reactFromHtml E2E tests Should rehydrate components with custom query selectors 1`] = `"<div class=\\"rehydration-root\\"><span>rehydrated component</span></div>"`;
6+
57
exports[`reactFromHtml E2E tests Should work for nested rehydratables 1`] = `
68
"
79
<div class=\\"rehydration-root\\"><span>

src/__tests__/tests.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,24 @@ describe("reactFromHtml E2E tests", async () => {
5151
expect(documentElement.innerHTML).toMatchSnapshot();
5252
expect(mockCall).toBeCalledTimes(2);
5353
});
54+
55+
it("Should rehydrate components with custom query selectors", async () => {
56+
const componentName: string = "myComponent";
57+
58+
const rehydrator = async () => {
59+
return React.createElement("span", {}, "rehydrated component");
60+
};
61+
62+
const rehydrators = { [componentName]: rehydrator };
63+
const documentElement = document.createElement("div");
64+
65+
documentElement.innerHTML = `<div class="test-${componentName}"></div>`;
66+
67+
await reactFromHtml(documentElement, rehydrators, {
68+
extra: {},
69+
getQuerySelector: key => `.test-${key}`,
70+
});
71+
72+
expect(documentElement.innerHTML).toMatchSnapshot();
73+
});
5474
});

src/rehydrator.ts

Lines changed: 56 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,24 @@
11
import * as ReactDOM from "react-dom";
22

33
import domElementToReact from "./dom-element-to-react";
4+
import ILoadedOptions from "./ILoadedOptions";
45
import IOptions from "./IOptions";
56
import IRehydrator from "./IRehydrator";
67

78
const rehydratableToReactElement = async (
89
el: Element,
910
rehydrators: IRehydrator,
10-
options: IOptions
11+
options: ILoadedOptions
1112
): Promise<React.ReactElement<any>> => {
12-
const rehydratorName = el.getAttribute("data-rehydratable");
13+
const rehydratorSelector = Object.keys(options.allSelectors).find(selector =>
14+
el.matches(selector)
15+
);
16+
17+
if (!rehydratorSelector) {
18+
throw new Error("No rehydrator selector matched the element.");
19+
}
20+
21+
const rehydratorName = options.allSelectors[rehydratorSelector];
1322

1423
if (!rehydratorName) {
1524
throw new Error("Rehydrator name is missing from element.");
@@ -33,13 +42,13 @@ const rehydratableToReactElement = async (
3342

3443
const createCustomHandler = (
3544
rehydrators: IRehydrator,
36-
options: IOptions
45+
options: ILoadedOptions
3746
) => async (node: Node) => {
3847
// This function will run on _every_ node that domElementToReact encounters.
3948
// Make sure to keep the conditional highly performant.
4049
if (
4150
node.nodeType === Node.ELEMENT_NODE &&
42-
(node as Element).hasAttribute("data-rehydratable")
51+
(node as Element).matches(options.compoundSelector)
4352
) {
4453
return rehydratableToReactElement(node as Element, rehydrators, options);
4554
}
@@ -63,7 +72,7 @@ const createReactRoot = (el: Node) => {
6372
const rehydrateChildren = async (
6473
el: Node,
6574
rehydrators: IRehydrator,
66-
options: IOptions
75+
options: ILoadedOptions
6776
) => {
6877
const container = createReactRoot(el);
6978

@@ -93,30 +102,55 @@ const render = ({
93102
ReactDOM.render(rehydrated as React.ReactElement<any>, root);
94103
};
95104

96-
const createQuerySelector = (rehydratableIds: string[]) =>
97-
rehydratableIds.reduce(
98-
(acc: string, rehydratableId: string) =>
99-
`${acc ? `${acc}, ` : ""}[data-rehydratable*="${rehydratableId}"]`,
105+
const defaultGetQuerySelector = (key: string) =>
106+
`[data-rehydratable*="${key}"]`;
107+
108+
const createQuerySelectors = (
109+
rehydratableIds: string[],
110+
getQuerySelector: ((key: string) => string) = defaultGetQuerySelector
111+
) => {
112+
const allSelectors: { [key: string]: string } = rehydratableIds.reduce(
113+
(acc, key) => ({ ...acc, [getQuerySelector(key)]: key }),
114+
{}
115+
);
116+
117+
const compoundSelector = Object.keys(allSelectors).reduce(
118+
(acc: string, selector: string) => `${acc ? `${acc}, ` : ""}${selector}`,
100119
""
101120
);
102121

122+
return {
123+
allSelectors,
124+
compoundSelector,
125+
};
126+
};
127+
103128
const rehydrate = async (
104129
container: Element,
105130
rehydrators: IRehydrator,
106131
options: IOptions
107132
) => {
108-
const selector = createQuerySelector(Object.keys(rehydrators));
109-
110-
const roots = Array.from(
111-
// TODO: allow setting a container identifier so multiple rehydration instances can exist
112-
container.querySelectorAll(selector)
113-
).reduce((acc: Element[], root: Element) => {
114-
// filter roots that are contained within other roots
115-
if (!acc.some(r => r.contains(root))) {
116-
acc.push(root);
117-
}
118-
return acc;
119-
}, []);
133+
const { allSelectors, compoundSelector } = createQuerySelectors(
134+
Object.keys(rehydrators),
135+
options.getQuerySelector
136+
);
137+
138+
const loadedOptions: ILoadedOptions = {
139+
allSelectors,
140+
compoundSelector,
141+
extra: options.extra,
142+
};
143+
144+
const roots = Array.from(container.querySelectorAll(compoundSelector)).reduce(
145+
(acc: Element[], root: Element) => {
146+
// filter roots that are contained within other roots
147+
if (!acc.some(r => r.contains(root))) {
148+
acc.push(root);
149+
}
150+
return acc;
151+
},
152+
[]
153+
);
120154

121155
// TODO: solve race condition when a second rehydrate runs
122156

@@ -130,7 +164,7 @@ const rehydrate = async (
130164
const {
131165
container: rootContainer,
132166
rehydrated,
133-
} = await rehydrateChildren(root, rehydrators, options);
167+
} = await rehydrateChildren(root, rehydrators, loadedOptions);
134168

135169
return { root: rootContainer, rehydrated };
136170
} catch (e) {

0 commit comments

Comments
 (0)