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; // // // Foo - 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,