mirror of
https://github.com/withastro/astro.git
synced 2025-02-17 22:44:24 -05:00
Fix solid recursion bug (#4215)
* Fix solid recursion bug * Fix types * Remove debug code * Remove logging from e2e test
This commit is contained in:
parent
58941e93c3
commit
0af5aa7a3b
17 changed files with 227 additions and 43 deletions
|
@ -0,0 +1,7 @@
|
||||||
|
import { defineConfig } from 'astro/config';
|
||||||
|
import solid from '@astrojs/solid-js';
|
||||||
|
|
||||||
|
// https://astro.build/config
|
||||||
|
export default defineConfig({
|
||||||
|
integrations: [solid()],
|
||||||
|
});
|
12
packages/astro/e2e/fixtures/solid-recurse/package.json
Normal file
12
packages/astro/e2e/fixtures/solid-recurse/package.json
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"name": "@e2e/solid-recurse",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@astrojs/solid-js": "workspace:*",
|
||||||
|
"astro": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"solid-js": "^1.4.3"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { createSignal } from 'solid-js';
|
||||||
|
|
||||||
|
export default function Counter(props) {
|
||||||
|
const [count, setCount] = createSignal(0);
|
||||||
|
const type = props.type;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
id={props.id + '-' + type}
|
||||||
|
data-type={type}
|
||||||
|
ref={(el) =>
|
||||||
|
console.log(
|
||||||
|
` ${type} ${type == el.dataset.type ? '==' : '!='} ${el.dataset.type}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
console.log('click');
|
||||||
|
setCount((p) => ++p);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{type}: {count()}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
import Counter from './Counter';
|
||||||
|
|
||||||
|
export default function WrapperA(props) {
|
||||||
|
return (
|
||||||
|
// removing the div avoids the error
|
||||||
|
<div data-wrapper-a-root>
|
||||||
|
<Counter id={props.id} type="A"></Counter>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
import Counter from './Counter';
|
||||||
|
|
||||||
|
export default function WrapperB(props) {
|
||||||
|
return (
|
||||||
|
<div id={props.id}>
|
||||||
|
{/* Reversing the order of these avoids the error: */}
|
||||||
|
<div data-wrapper-children>{props.children}</div>
|
||||||
|
<Counter id={props.id} type="B"></Counter>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
---
|
||||||
|
import WrapperB from "../components/WrapperB.jsx";
|
||||||
|
import WrapperA from "../components/WrapperA.jsx";
|
||||||
|
---
|
||||||
|
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
Case 1
|
||||||
|
<WrapperB id="case1" client:load>
|
||||||
|
<WrapperA id="case1" />
|
||||||
|
</WrapperB>
|
||||||
|
<br/>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
29
packages/astro/e2e/solid-recurse.test.js
Normal file
29
packages/astro/e2e/solid-recurse.test.js
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import { expect } from '@playwright/test';
|
||||||
|
import { testFactory } from './test-utils.js';
|
||||||
|
|
||||||
|
const test = testFactory({ root: './fixtures/solid-recurse/' });
|
||||||
|
|
||||||
|
let devServer;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ astro }) => {
|
||||||
|
devServer = await astro.startDevServer();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
await devServer.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Recursive elements with Solid', () => {
|
||||||
|
test('Counter', async ({ astro, page }) => {
|
||||||
|
await page.goto(astro.resolveUrl('/'));
|
||||||
|
|
||||||
|
const wrapper = page.locator('#case1');
|
||||||
|
await expect(wrapper, 'component is visible').toBeVisible();
|
||||||
|
|
||||||
|
const increment = page.locator('#case1-B');
|
||||||
|
await expect(increment, 'initial count is 0').toHaveText('B: 0');
|
||||||
|
|
||||||
|
await increment.click();
|
||||||
|
await expect(increment, 'count is incremented').toHaveText('B: 1');
|
||||||
|
});
|
||||||
|
});
|
|
@ -1024,6 +1024,7 @@ export interface SSRLoadedRenderer extends AstroRenderer {
|
||||||
check: AsyncRendererComponentFn<boolean>;
|
check: AsyncRendererComponentFn<boolean>;
|
||||||
renderToStaticMarkup: AsyncRendererComponentFn<{
|
renderToStaticMarkup: AsyncRendererComponentFn<{
|
||||||
html: string;
|
html: string;
|
||||||
|
attrs?: Record<string, string>;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -107,6 +107,7 @@ interface HydrateScriptOptions {
|
||||||
result: SSRResult;
|
result: SSRResult;
|
||||||
astroId: string;
|
astroId: string;
|
||||||
props: Record<string | number, any>;
|
props: Record<string | number, any>;
|
||||||
|
attrs: Record<string, string> | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** For hydrated components, generate a <script type="module"> to load the component */
|
/** For hydrated components, generate a <script type="module"> to load the component */
|
||||||
|
@ -114,7 +115,7 @@ export async function generateHydrateScript(
|
||||||
scriptOptions: HydrateScriptOptions,
|
scriptOptions: HydrateScriptOptions,
|
||||||
metadata: Required<AstroComponentMetadata>
|
metadata: Required<AstroComponentMetadata>
|
||||||
): Promise<SSRElement> {
|
): Promise<SSRElement> {
|
||||||
const { renderer, result, astroId, props } = scriptOptions;
|
const { renderer, result, astroId, props, attrs } = scriptOptions;
|
||||||
const { hydrate, componentUrl, componentExport } = metadata;
|
const { hydrate, componentUrl, componentExport } = metadata;
|
||||||
|
|
||||||
if (!componentExport) {
|
if (!componentExport) {
|
||||||
|
@ -131,6 +132,13 @@ export async function generateHydrateScript(
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Attach renderer-provided attributes
|
||||||
|
if(attrs) {
|
||||||
|
for(const [key, value] of Object.entries(attrs)) {
|
||||||
|
island.props[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add component url
|
// Add component url
|
||||||
island.props['component-url'] = await result.resolve(componentUrl);
|
island.props['component-url'] = await result.resolve(componentUrl);
|
||||||
|
|
||||||
|
|
|
@ -102,6 +102,7 @@ export async function renderComponent(
|
||||||
|
|
||||||
const { hydration, isPage, props } = extractDirectives(_props);
|
const { hydration, isPage, props } = extractDirectives(_props);
|
||||||
let html = '';
|
let html = '';
|
||||||
|
let attrs: Record<string, string> | undefined = undefined;
|
||||||
|
|
||||||
if (hydration) {
|
if (hydration) {
|
||||||
metadata.hydrate = hydration.directive as AstroComponentMetadata['hydrate'];
|
metadata.hydrate = hydration.directive as AstroComponentMetadata['hydrate'];
|
||||||
|
@ -222,7 +223,7 @@ Did you mean to enable ${formatList(probableRendererNames.map((r) => '`' + r + '
|
||||||
// We already know that renderer.ssr.check() has failed
|
// We already know that renderer.ssr.check() has failed
|
||||||
// but this will throw a much more descriptive error!
|
// but this will throw a much more descriptive error!
|
||||||
renderer = matchingRenderers[0];
|
renderer = matchingRenderers[0];
|
||||||
({ html } = await renderer.ssr.renderToStaticMarkup.call(
|
({ html, attrs } = await renderer.ssr.renderToStaticMarkup.call(
|
||||||
{ result },
|
{ result },
|
||||||
Component,
|
Component,
|
||||||
props,
|
props,
|
||||||
|
@ -247,7 +248,7 @@ If you're still stuck, please open an issue on GitHub or join us at https://astr
|
||||||
if (metadata.hydrate === 'only') {
|
if (metadata.hydrate === 'only') {
|
||||||
html = await renderSlot(result, slots?.fallback);
|
html = await renderSlot(result, slots?.fallback);
|
||||||
} else {
|
} else {
|
||||||
({ html } = await renderer.ssr.renderToStaticMarkup.call(
|
({ html, attrs } = await renderer.ssr.renderToStaticMarkup.call(
|
||||||
{ result },
|
{ result },
|
||||||
Component,
|
Component,
|
||||||
props,
|
props,
|
||||||
|
@ -302,7 +303,7 @@ If you're still stuck, please open an issue on GitHub or join us at https://astr
|
||||||
);
|
);
|
||||||
|
|
||||||
const island = await generateHydrateScript(
|
const island = await generateHydrateScript(
|
||||||
{ renderer: renderer!, result, astroId, props },
|
{ renderer: renderer!, result, astroId, props, attrs },
|
||||||
metadata as Required<AstroComponentMetadata>
|
metadata as Required<AstroComponentMetadata>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -22,8 +22,8 @@
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./dist/index.js",
|
".": "./dist/index.js",
|
||||||
"./*": "./*",
|
"./*": "./*",
|
||||||
"./client.js": "./client.js",
|
"./client.js": "./dist/client.js",
|
||||||
"./server.js": "./server.js",
|
"./server.js": "./dist/server.js",
|
||||||
"./package.json": "./package.json"
|
"./package.json": "./package.json"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
@ -1,31 +0,0 @@
|
||||||
import { renderToString, ssr, createComponent } from 'solid-js/web';
|
|
||||||
|
|
||||||
const slotName = (str) => str.trim().replace(/[-_]([a-z])/g, (_, w) => w.toUpperCase());
|
|
||||||
|
|
||||||
function check(Component, props, children) {
|
|
||||||
if (typeof Component !== 'function') return false;
|
|
||||||
const { html } = renderToStaticMarkup(Component, props, children);
|
|
||||||
return typeof html === 'string';
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderToStaticMarkup(Component, props, { default: children, ...slotted }) {
|
|
||||||
const slots = {};
|
|
||||||
for (const [key, value] of Object.entries(slotted)) {
|
|
||||||
const name = slotName(key);
|
|
||||||
slots[name] = ssr(`<astro-slot name="${name}">${value}</astro-slot>`);
|
|
||||||
}
|
|
||||||
// Note: create newProps to avoid mutating `props` before they are serialized
|
|
||||||
const newProps = {
|
|
||||||
...props,
|
|
||||||
...slots,
|
|
||||||
// In Solid SSR mode, `ssr` creates the expected structure for `children`.
|
|
||||||
children: children != null ? ssr(`<astro-slot>${children}</astro-slot>`) : children,
|
|
||||||
};
|
|
||||||
const html = renderToString(() => createComponent(Component, newProps));
|
|
||||||
return { html };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
check,
|
|
||||||
renderToStaticMarkup,
|
|
||||||
};
|
|
|
@ -1,17 +1,17 @@
|
||||||
import { sharedConfig } from 'solid-js';
|
import { sharedConfig } from 'solid-js';
|
||||||
import { hydrate, render, createComponent } from 'solid-js/web';
|
import { hydrate, render, createComponent } from 'solid-js/web';
|
||||||
|
|
||||||
export default (element) =>
|
export default (element: HTMLElement) =>
|
||||||
(Component, props, slotted, { client }) => {
|
(Component: any, props: any, slotted: any, { client }: { client: string }) => {
|
||||||
// Prepare global object expected by Solid's hydration logic
|
// Prepare global object expected by Solid's hydration logic
|
||||||
if (!window._$HY) {
|
if (!(window as any)._$HY) {
|
||||||
window._$HY = { events: [], completed: new WeakSet(), r: {} };
|
(window as any)._$HY = { events: [], completed: new WeakSet(), r: {} };
|
||||||
}
|
}
|
||||||
if (!element.hasAttribute('ssr')) return;
|
if (!element.hasAttribute('ssr')) return;
|
||||||
|
|
||||||
const fn = client === 'only' ? render : hydrate;
|
const fn = client === 'only' ? render : hydrate;
|
||||||
|
|
||||||
let _slots = {};
|
let _slots: Record<string, any> = {};
|
||||||
if (Object.keys(slotted).length > 0) {
|
if (Object.keys(slotted).length > 0) {
|
||||||
// hydrating
|
// hydrating
|
||||||
if (sharedConfig.context) {
|
if (sharedConfig.context) {
|
||||||
|
@ -28,6 +28,7 @@ export default (element) =>
|
||||||
}
|
}
|
||||||
|
|
||||||
const { default: children, ...slots } = _slots;
|
const { default: children, ...slots } = _slots;
|
||||||
|
const renderId = element.dataset.solidRenderId;
|
||||||
|
|
||||||
fn(
|
fn(
|
||||||
() =>
|
() =>
|
||||||
|
@ -36,6 +37,9 @@ export default (element) =>
|
||||||
...slots,
|
...slots,
|
||||||
children,
|
children,
|
||||||
}),
|
}),
|
||||||
element
|
element,
|
||||||
|
{
|
||||||
|
renderId
|
||||||
|
}
|
||||||
);
|
);
|
||||||
};
|
};
|
28
packages/integrations/solid/src/context.ts
Normal file
28
packages/integrations/solid/src/context.ts
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import type { RendererContext } from './types';
|
||||||
|
|
||||||
|
type Context = {
|
||||||
|
id: string;
|
||||||
|
c: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contexts = new WeakMap<RendererContext['result'], Context>();
|
||||||
|
|
||||||
|
export function getContext(result: RendererContext['result']): Context {
|
||||||
|
if(contexts.has(result)) {
|
||||||
|
return contexts.get(result)!;
|
||||||
|
}
|
||||||
|
let ctx = {
|
||||||
|
c: 0,
|
||||||
|
get id() {
|
||||||
|
return 's' + this.c.toString();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
contexts.set(result, ctx);
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function incrementId(ctx: Context): string {
|
||||||
|
let id = ctx.id;
|
||||||
|
ctx.c++;
|
||||||
|
return id;
|
||||||
|
}
|
45
packages/integrations/solid/src/server.ts
Normal file
45
packages/integrations/solid/src/server.ts
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import type { RendererContext } from './types';
|
||||||
|
import { renderToString, ssr, createComponent } from 'solid-js/web';
|
||||||
|
import { getContext, incrementId } from './context.js';
|
||||||
|
|
||||||
|
const slotName = (str: string) => str.trim().replace(/[-_]([a-z])/g, (_, w) => w.toUpperCase());
|
||||||
|
|
||||||
|
function check(this: RendererContext, Component: any, props: Record<string, any>, children: any) {
|
||||||
|
if (typeof Component !== 'function') return false;
|
||||||
|
const { html } = renderToStaticMarkup.call(this, Component, props, children);
|
||||||
|
return typeof html === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderToStaticMarkup(this: RendererContext, Component: any, props: Record<string, any>, { default: children, ...slotted }: any, metadata?: undefined | Record<string, any>) {
|
||||||
|
const renderId = metadata?.hydrate ? incrementId(getContext(this.result)) : '';
|
||||||
|
|
||||||
|
const html = renderToString(() => {
|
||||||
|
const slots: Record<string, any> = {};
|
||||||
|
for (const [key, value] of Object.entries(slotted)) {
|
||||||
|
const name = slotName(key);
|
||||||
|
slots[name] = ssr(`<astro-slot name="${name}">${value}</astro-slot>`);
|
||||||
|
}
|
||||||
|
// Note: create newProps to avoid mutating `props` before they are serialized
|
||||||
|
const newProps = {
|
||||||
|
...props,
|
||||||
|
...slots,
|
||||||
|
// In Solid SSR mode, `ssr` creates the expected structure for `children`.
|
||||||
|
children: children != null ? ssr(`<astro-slot>${children}</astro-slot>`) : children,
|
||||||
|
};
|
||||||
|
|
||||||
|
return createComponent(Component, newProps);
|
||||||
|
}, {
|
||||||
|
renderId
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
attrs: {
|
||||||
|
'data-solid-render-id': renderId
|
||||||
|
},
|
||||||
|
html
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
check,
|
||||||
|
renderToStaticMarkup,
|
||||||
|
};
|
4
packages/integrations/solid/src/types.ts
Normal file
4
packages/integrations/solid/src/types.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
import type { SSRResult } from 'astro';
|
||||||
|
export type RendererContext = {
|
||||||
|
result: SSRResult;
|
||||||
|
};
|
11
pnpm-lock.yaml
generated
11
pnpm-lock.yaml
generated
|
@ -948,6 +948,17 @@ importers:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
solid-js: 1.4.8
|
solid-js: 1.4.8
|
||||||
|
|
||||||
|
packages/astro/e2e/fixtures/solid-recurse:
|
||||||
|
specifiers:
|
||||||
|
'@astrojs/solid-js': workspace:*
|
||||||
|
astro: workspace:*
|
||||||
|
solid-js: ^1.4.3
|
||||||
|
dependencies:
|
||||||
|
'@astrojs/solid-js': link:../../../../integrations/solid
|
||||||
|
astro: link:../../..
|
||||||
|
devDependencies:
|
||||||
|
solid-js: 1.4.8
|
||||||
|
|
||||||
packages/astro/e2e/fixtures/svelte-component:
|
packages/astro/e2e/fixtures/svelte-component:
|
||||||
specifiers:
|
specifiers:
|
||||||
'@astrojs/mdx': workspace:*
|
'@astrojs/mdx': workspace:*
|
||||||
|
|
Loading…
Add table
Reference in a new issue