diff --git a/src/partialHydration/__tests__/partialHydration.spec.ts b/src/partialHydration/__tests__/partialHydration.spec.ts
index bda87588..a0060609 100644
--- a/src/partialHydration/__tests__/partialHydration.spec.ts
+++ b/src/partialHydration/__tests__/partialHydration.spec.ts
@@ -8,8 +8,8 @@ describe('#partialHydration', () => {
content: '',
})
).code,
- ).toEqual(
- `
`,
+ ).toMatchInlineSnapshot(
+ `"{#if ({}).loading === 'none'}{#else}
{/if}"`,
);
});
@@ -20,8 +20,8 @@ describe('#partialHydration', () => {
content: '',
})
).code,
- ).toEqual(
- ``,
+ ).toMatchInlineSnapshot(
+ `"{#if ({ \\"loading\\": \\"lazy\\" }).loading === 'none'}{#else}
{/if}"`,
);
});
@@ -32,8 +32,8 @@ describe('#partialHydration', () => {
content: '',
})
).code,
- ).toEqual(
- ``,
+ ).toMatchInlineSnapshot(
+ `"{#if ({ \\"timeout\\": 2000 }).loading === 'none'}{#else}
{/if}"`,
);
});
@@ -44,8 +44,8 @@ describe('#partialHydration', () => {
content: '',
})
).code,
- ).toEqual(
- ``,
+ ).toMatchInlineSnapshot(
+ `"{#if ({ \\"loading\\": \\"eager\\" }).loading === 'none'}{#else}
{/if}"`,
);
});
it('eager, root margin, threshold', async () => {
@@ -56,18 +56,16 @@ describe('#partialHydration', () => {
'',
})
).code,
- ).toEqual(
- ``,
+ ).toMatchInlineSnapshot(
+ `"{#if ({ \\"loading\\": \\"eager\\", \\"rootMargin\\": \\"500px\\", \\"threshold\\": 0 }).loading === 'none'}{#else}
{/if}"`,
);
});
it('open string', async () => {
- expect(
- (
- await partialHydration.markup({
- content: '`);
+ await expect(async () => {
+ await partialHydration.markup({
+ content: '`,
})
).code,
- ).toEqual(
- ``,
+ ).toMatchInlineSnapshot(
+ `"{#if ({ \\"loading\\": \\"eager\\", \\"preload\\": true }).loading === 'none'}{#else}
{/if}{#if ({ \\"loading\\": \\"lazy\\" }).loading === 'none'}{#else}
{/if}{#if ({ \\"loading\\": \\"lazy\\" }).loading === 'none'}{#else}{/if}"`,
+ );
+ });
+
+ it('options as identifier', async () => {
+ expect(
+ (
+ await partialHydration.markup({
+ content: '',
+ })
+ ).code,
+ ).toMatchInlineSnapshot(
+ `"{#if (foo).loading === 'none'}{#else}
{/if}"`,
+ );
+ });
+
+ it('ssr props', async () => {
+ expect(
+ (
+ await partialHydration.markup({
+ content: '',
+ })
+ ).code,
+ ).toMatchInlineSnapshot(
+ `"{#if ({}).loading === 'none'}{#else}
{/if}"`,
+ );
+ });
+
+ it.skip('ssr props expression in string', async () => {
+ expect(
+ (
+ await partialHydration.markup({
+ content: '',
+ })
+ ).code,
+ ).toMatchInlineSnapshot(
+ `"{#if ({}).loading === 'none'}{#else}
{/if}"`,
+ );
+ });
+
+ it('ssr props no name', async () => {
+ expect(
+ (
+ await partialHydration.markup({
+ content: '',
+ })
+ ).code,
+ ).toMatchInlineSnapshot(
+ `"{#if ({}).loading === 'none'}{#else}
{/if}"`,
+ );
+ });
+
+ it('ssr props spread', async () => {
+ expect(
+ (
+ await partialHydration.markup({
+ content: '',
+ })
+ ).code,
+ ).toMatchInlineSnapshot(
+ `"{#if ({}).loading === 'none'}{#else}
{/if}"`,
+ );
+ });
+
+ it('style props', async () => {
+ expect(
+ (
+ await partialHydration.markup({
+ content: '',
+ })
+ ).code,
+ ).toMatchInlineSnapshot(
+ `"{#if ({}).loading === 'none'}{#else}
{/if}"`,
+ );
+ });
+
+ it('style props with expression', async () => {
+ expect(
+ (
+ await partialHydration.markup({
+ content: '',
+ })
+ ).code,
+ ).toMatchInlineSnapshot(
+ `"{#if ({}).loading === 'none'}{#else}
{/if}"`,
);
});
});
diff --git a/src/partialHydration/partialHydration.ts b/src/partialHydration/partialHydration.ts
index 45c821fe..ebc1210e 100644
--- a/src/partialHydration/partialHydration.ts
+++ b/src/partialHydration/partialHydration.ts
@@ -1,3 +1,5 @@
+import MagicString from 'magic-string';
+import { parseTag } from '../utils/htmlParser';
import { inlinePreprocessedSvelteComponent } from './inlineSvelteComponent';
const extractHydrateOptions = (htmlString) => {
@@ -10,36 +12,61 @@ const extractHydrateOptions = (htmlString) => {
return '';
};
-const createReplacementString = ({ input, name, props }) => {
- const options = extractHydrateOptions(input);
- return inlinePreprocessedSvelteComponent({ name, props, options });
+const createReplacementString = (content, tag) => {
+ let options = '{}';
+ let clientProps = '{}';
+ let styleProps = '';
+ let stylePropsRaw = '';
+ let serverProps = '';
+ for (const attr of tag.attrs) {
+ if (/^hydrate-client$/i.test(attr.name)) {
+ if (attr.value) {
+ clientProps = content.slice(attr.value.exp.start, attr.value.exp.end);
+ }
+ } else if (/^hydrate-options$/i.test(attr.name)) {
+ options = content.slice(attr.value.exp.start, attr.value.exp.end);
+ } else if (/^--/i.test(attr.name)) {
+ stylePropsRaw += ` ${content.slice(attr.start, attr.end)}`;
+ styleProps += ` style:${attr.name}=${content.slice(attr.value.start, attr.value.end)}`;
+ } else {
+ serverProps += ` ${content.slice(attr.start, attr.end)}`;
+ }
+ }
+
+ return `{#if (${options}).loading === 'none'}<${tag.name} {...(${clientProps})} ${styleProps} ${serverProps}/>` +
+ `{#else}` +
+ `<${tag.name} ${stylePropsRaw} ${serverProps}/>
{/if}`
};
export const preprocessSvelteContent = (content) => {
// Note: this regex only supports self closing components.
// Slots aren't supported for client hydration either.
- const hydrateableComponentPattern = /<([a-zA-Z]+)[^>]+hydrate-client={([^]*?})}[^/>]*\/>/gim;
- const matches = [...content.matchAll(hydrateableComponentPattern)];
-
- const output = matches.reduce((out, match) => {
- const [wholeMatch, name, props] = match;
- const replacement = createReplacementString({ input: wholeMatch, name, props });
- return out.replace(wholeMatch, replacement);
- }, content);
+ let dirty = false;
+ const hydrateableComponentPattern = /<([a-zA-Z]+)[^>]+hydrate-client/gim;
+ const s = new MagicString(content);
+ for (const match of content.matchAll(hydrateableComponentPattern)) {
+ const tag = parseTag(content, match.index);
+ if (!tag.selfClosed) {
+ throw new Error("Hydratable component must be a self-closing tag");
+ }
+ s.overwrite(tag.start, tag.end, createReplacementString(content, tag));
+ dirty = true;
+ }
const wrappingComponentPattern = /<([a-zA-Z]+)[^>]+hydrate-client={([^]*?})}[^/>]*>[^>]*<\/([a-zA-Z]+)>/gim;
//
//
//
- const wrappedComponents = [...output.matchAll(wrappingComponentPattern)];
+ const wrappedComponents = [...content.matchAll(wrappingComponentPattern)];
if (wrappedComponents && wrappedComponents.length > 0) {
throw new Error(
`Elder.js only supports self-closing syntax on hydrated components. This means not or Something. Offending component: ${wrappedComponents[0][0]}. Slots and child components aren't supported during hydration as it would result in huge HTML payloads. If you need this functionality try wrapping the offending component in a parent component without slots or child components and hydrate the parent component.`,
);
}
- return output;
+ return dirty ? s.toString() : content;
};
const partialHydration = {
diff --git a/src/utils/__tests__/__snapshots__/htmlParser.ts.snap b/src/utils/__tests__/__snapshots__/htmlParser.ts.snap
index 24eda944..97828f4e 100644
--- a/src/utils/__tests__/__snapshots__/htmlParser.ts.snap
+++ b/src/utils/__tests__/__snapshots__/htmlParser.ts.snap
@@ -70,6 +70,7 @@ Object {
"start": 41,
"type": "Identifier",
},
+ "spread": false,
"start": 40,
},
},
diff --git a/src/utils/htmlParser.ts b/src/utils/htmlParser.ts
index f50cf6bf..63255c72 100644
--- a/src/utils/htmlParser.ts
+++ b/src/utils/htmlParser.ts
@@ -7,7 +7,7 @@ type Node = {
type TagNode = Node & {
name: string;
- attrs: Array;
+ attrs: Array;
selfClosed: boolean;
start: number;
end: number;
@@ -20,10 +20,14 @@ type AttrNode = Node & {
type AttrValueNode = Node & {
value?: string;
- exp?: any;
raw?: string;
}
+type ExpressionNode = Node & {
+ exp: any;
+ spread: boolean;
+}
+
export function escapeHtml(text: string): string {
return text
.replace(/&/g, '&')
@@ -43,7 +47,23 @@ export const unescapeHtml = (str) =>
.replace(/\\"/gim, '"')
.replace(/&/gim, '&');
-export function parseAttrValue(content: string, index: number): AttrValueNode {
+export function parseExpression(content: string, index: number): ExpressionNode {
+ const rx = /{\s*(\.\.\.)?/y;
+ rx.lastIndex = index;
+ const [prefix,, hasSpread] = rx.exec(content);
+ const exp = parseExpressionAt(content, index + prefix.length);
+ const rxEnd = /\s*}/y;
+ rxEnd.lastIndex = exp.end;
+ rxEnd.exec(content);
+ return {
+ start: index,
+ end: rxEnd.lastIndex,
+ exp,
+ spread: Boolean(hasSpread)
+ };
+}
+
+export function parseAttrValue(content: string, index: number): AttrValueNode | ExpressionNode {
let raw;
let rx;
if (content[index] === "'") {
@@ -55,15 +75,7 @@ export function parseAttrValue(content: string, index: number): AttrValueNode {
rx.lastIndex = index;
raw = content.match(rx)[1];
} else if (content[index] === "{") {
- const node = parseExpressionAt(content, index + 1);
- const rxEnd = /\s*}/y;
- rxEnd.lastIndex = node.end;
- content.match(rxEnd);
- return {
- start: index,
- end: rxEnd.lastIndex,
- exp: node
- };
+ return parseExpression(content, index);
} else {
rx = /[^\s"'=<>\/\x7f-\x9f]+/y;
rx.lastIndex = index;
@@ -98,13 +110,20 @@ export function parseTag(content: string, index: number): TagNode {
};
}
-export function parseAttrs(content: string, index: number): Array {
- const rx = /\s+([\w-:]+)(\s*=\s*)?/y;
+export function parseAttrs(content: string, index: number): Array {
+ const rx = /(\s+)(?:([\w-:]+)(\s*=\s*)?|{)/y;
rx.lastIndex = index;
const result = [];
let match;
while ((match = rx.exec(content))) {
- const [, name, hasValue] = match;
+ const [, prefix, name, hasValue] = match;
+ if (!name) {
+ // expression
+ const node = parseExpression(content, match.index + prefix.length);
+ result.push(node);
+ rx.lastIndex = node.end;
+ continue;
+ }
const value = hasValue ? parseAttrValue(content, rx.lastIndex) : null;
result.push({
start: match.index,