diff --git a/src/ILoadedOptions.ts b/src/ILoadedOptions.ts
new file mode 100644
index 0000000..dd10fd2
--- /dev/null
+++ b/src/ILoadedOptions.ts
@@ -0,0 +1,7 @@
+interface IOptions {
+ allSelectors: { [key: string]: string };
+ compoundSelector: string;
+ extra: object;
+}
+
+export default IOptions;
diff --git a/src/IOptions.ts b/src/IOptions.ts
index 888b0d6..404ac44 100644
--- a/src/IOptions.ts
+++ b/src/IOptions.ts
@@ -1,5 +1,6 @@
interface IOptions {
extra: object;
+ getQuerySelector?: (key: string) => string;
}
export default IOptions;
diff --git a/src/__tests__/__snapshots__/tests.ts.snap b/src/__tests__/__snapshots__/tests.ts.snap
index cd4d07f..60d3b23 100644
--- a/src/__tests__/__snapshots__/tests.ts.snap
+++ b/src/__tests__/__snapshots__/tests.ts.snap
@@ -2,6 +2,8 @@
exports[`reactFromHtml E2E tests Should rehydrate a basic component 1`] = `"
rehydrated component
"`;
+exports[`reactFromHtml E2E tests Should rehydrate components with custom query selectors 1`] = `"rehydrated component
"`;
+
exports[`reactFromHtml E2E tests Should work for nested rehydratables 1`] = `
"
diff --git a/src/__tests__/tests.ts b/src/__tests__/tests.ts
index a91709b..5d59369 100644
--- a/src/__tests__/tests.ts
+++ b/src/__tests__/tests.ts
@@ -51,4 +51,24 @@ describe("reactFromHtml E2E tests", async () => {
expect(documentElement.innerHTML).toMatchSnapshot();
expect(mockCall).toBeCalledTimes(2);
});
+
+ it("Should rehydrate components with custom query selectors", async () => {
+ const componentName: string = "myComponent";
+
+ const rehydrator = async () => {
+ return React.createElement("span", {}, "rehydrated component");
+ };
+
+ const rehydrators = { [componentName]: rehydrator };
+ const documentElement = document.createElement("div");
+
+ documentElement.innerHTML = ``;
+
+ await reactFromHtml(documentElement, rehydrators, {
+ extra: {},
+ getQuerySelector: key => `.test-${key}`,
+ });
+
+ expect(documentElement.innerHTML).toMatchSnapshot();
+ });
});
diff --git a/src/rehydrator.ts b/src/rehydrator.ts
index 72426e9..2dd0465 100644
--- a/src/rehydrator.ts
+++ b/src/rehydrator.ts
@@ -1,15 +1,24 @@
import * as ReactDOM from "react-dom";
import domElementToReact from "./dom-element-to-react";
+import ILoadedOptions from "./ILoadedOptions";
import IOptions from "./IOptions";
import IRehydrator from "./IRehydrator";
const rehydratableToReactElement = async (
el: Element,
rehydrators: IRehydrator,
- options: IOptions
+ options: ILoadedOptions
): Promise> => {
- const rehydratorName = el.getAttribute("data-rehydratable");
+ const rehydratorSelector = Object.keys(options.allSelectors).find(selector =>
+ el.matches(selector)
+ );
+
+ if (!rehydratorSelector) {
+ throw new Error("No rehydrator selector matched the element.");
+ }
+
+ const rehydratorName = options.allSelectors[rehydratorSelector];
if (!rehydratorName) {
throw new Error("Rehydrator name is missing from element.");
@@ -33,13 +42,13 @@ const rehydratableToReactElement = async (
const createCustomHandler = (
rehydrators: IRehydrator,
- options: IOptions
+ options: ILoadedOptions
) => async (node: Node) => {
// This function will run on _every_ node that domElementToReact encounters.
// Make sure to keep the conditional highly performant.
if (
node.nodeType === Node.ELEMENT_NODE &&
- (node as Element).hasAttribute("data-rehydratable")
+ (node as Element).matches(options.compoundSelector)
) {
return rehydratableToReactElement(node as Element, rehydrators, options);
}
@@ -63,7 +72,7 @@ const createReactRoot = (el: Node) => {
const rehydrateChildren = async (
el: Node,
rehydrators: IRehydrator,
- options: IOptions
+ options: ILoadedOptions
) => {
const container = createReactRoot(el);
@@ -93,30 +102,55 @@ const render = ({
ReactDOM.render(rehydrated as React.ReactElement, root);
};
-const createQuerySelector = (rehydratableIds: string[]) =>
- rehydratableIds.reduce(
- (acc: string, rehydratableId: string) =>
- `${acc ? `${acc}, ` : ""}[data-rehydratable*="${rehydratableId}"]`,
+const defaultGetQuerySelector = (key: string) =>
+ `[data-rehydratable*="${key}"]`;
+
+const createQuerySelectors = (
+ rehydratableIds: string[],
+ getQuerySelector: ((key: string) => string) = defaultGetQuerySelector
+) => {
+ const allSelectors: { [key: string]: string } = rehydratableIds.reduce(
+ (acc, key) => ({ ...acc, [getQuerySelector(key)]: key }),
+ {}
+ );
+
+ const compoundSelector = Object.keys(allSelectors).reduce(
+ (acc: string, selector: string) => `${acc ? `${acc}, ` : ""}${selector}`,
""
);
+ return {
+ allSelectors,
+ compoundSelector,
+ };
+};
+
const rehydrate = async (
container: Element,
rehydrators: IRehydrator,
options: IOptions
) => {
- const selector = createQuerySelector(Object.keys(rehydrators));
-
- const roots = Array.from(
- // TODO: allow setting a container identifier so multiple rehydration instances can exist
- container.querySelectorAll(selector)
- ).reduce((acc: Element[], root: Element) => {
- // filter roots that are contained within other roots
- if (!acc.some(r => r.contains(root))) {
- acc.push(root);
- }
- return acc;
- }, []);
+ const { allSelectors, compoundSelector } = createQuerySelectors(
+ Object.keys(rehydrators),
+ options.getQuerySelector
+ );
+
+ const loadedOptions: ILoadedOptions = {
+ allSelectors,
+ compoundSelector,
+ extra: options.extra,
+ };
+
+ const roots = Array.from(container.querySelectorAll(compoundSelector)).reduce(
+ (acc: Element[], root: Element) => {
+ // filter roots that are contained within other roots
+ if (!acc.some(r => r.contains(root))) {
+ acc.push(root);
+ }
+ return acc;
+ },
+ []
+ );
// TODO: solve race condition when a second rehydrate runs
@@ -130,7 +164,7 @@ const rehydrate = async (
const {
container: rootContainer,
rehydrated,
- } = await rehydrateChildren(root, rehydrators, options);
+ } = await rehydrateChildren(root, rehydrators, loadedOptions);
return { root: rootContainer, rehydrated };
} catch (e) {