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
7 changes: 7 additions & 0 deletions src/ILoadedOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
interface IOptions {
allSelectors: { [key: string]: string };
compoundSelector: string;
extra: object;
}

export default IOptions;
1 change: 1 addition & 0 deletions src/IOptions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
interface IOptions {
extra: object;
getQuerySelector?: (key: string) => string;
}

export default IOptions;
2 changes: 2 additions & 0 deletions src/__tests__/__snapshots__/tests.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

exports[`reactFromHtml E2E tests Should rehydrate a basic component 1`] = `"<div class=\\"rehydration-root\\"><span>rehydrated component</span></div>"`;

exports[`reactFromHtml E2E tests Should rehydrate components with custom query selectors 1`] = `"<div class=\\"rehydration-root\\"><span>rehydrated component</span></div>"`;

exports[`reactFromHtml E2E tests Should work for nested rehydratables 1`] = `
"
<div class=\\"rehydration-root\\"><span>
Expand Down
20 changes: 20 additions & 0 deletions src/__tests__/tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<div class="test-${componentName}"></div>`;

await reactFromHtml(documentElement, rehydrators, {
extra: {},
getQuerySelector: key => `.test-${key}`,
});

expect(documentElement.innerHTML).toMatchSnapshot();
});
});
78 changes: 56 additions & 22 deletions src/rehydrator.ts
Original file line number Diff line number Diff line change
@@ -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<React.ReactElement<any>> => {
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.");
Expand All @@ -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);
}
Expand All @@ -63,7 +72,7 @@ const createReactRoot = (el: Node) => {
const rehydrateChildren = async (
el: Node,
rehydrators: IRehydrator,
options: IOptions
options: ILoadedOptions
) => {
const container = createReactRoot(el);

Expand Down Expand Up @@ -93,30 +102,55 @@ const render = ({
ReactDOM.render(rehydrated as React.ReactElement<any>, 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

Expand All @@ -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) {
Expand Down