diff options
Diffstat (limited to 'packages/integrations/solid/src')
-rw-r--r-- | packages/integrations/solid/src/client.ts | 77 | ||||
-rw-r--r-- | packages/integrations/solid/src/context.ts | 28 | ||||
-rw-r--r-- | packages/integrations/solid/src/index.ts | 125 | ||||
-rw-r--r-- | packages/integrations/solid/src/server.ts | 143 | ||||
-rw-r--r-- | packages/integrations/solid/src/types.ts | 4 |
5 files changed, 377 insertions, 0 deletions
diff --git a/packages/integrations/solid/src/client.ts b/packages/integrations/solid/src/client.ts new file mode 100644 index 000000000..f2020bb56 --- /dev/null +++ b/packages/integrations/solid/src/client.ts @@ -0,0 +1,77 @@ +import { Suspense } from 'solid-js'; +import { createStore, reconcile } from 'solid-js/store'; +import { createComponent, hydrate, render } from 'solid-js/web'; + +const alreadyInitializedElements = new WeakMap<Element, any>(); + +export default (element: HTMLElement) => + (Component: any, props: any, slotted: any, { client }: { client: string }) => { + if (!element.hasAttribute('ssr')) return; + const isHydrate = client !== 'only'; + const bootstrap = isHydrate ? hydrate : render; + + let slot: HTMLElement | null; + let _slots: Record<string, any> = {}; + if (Object.keys(slotted).length > 0) { + // hydratable + if (client !== 'only') { + const iterator = document.createTreeWalker(element, NodeFilter.SHOW_ELEMENT, (node) => { + if (node === element) return NodeFilter.FILTER_SKIP; + if (node.nodeName === 'ASTRO-SLOT') return NodeFilter.FILTER_ACCEPT; + if (node.nodeName === 'ASTRO-ISLAND') return NodeFilter.FILTER_REJECT; + return NodeFilter.FILTER_SKIP; + }); + while ((slot = iterator.nextNode() as HTMLElement | null)) + _slots[slot.getAttribute('name') || 'default'] = slot; + } + for (const [key, value] of Object.entries(slotted)) { + if (_slots[key]) continue; + _slots[key] = document.createElement('astro-slot'); + if (key !== 'default') _slots[key].setAttribute('name', key); + _slots[key].innerHTML = value; + } + } + + const { default: children, ...slots } = _slots; + const renderId = element.dataset.solidRenderId; + if (alreadyInitializedElements.has(element)) { + // update the mounted component + alreadyInitializedElements.get(element)!( + // reconcile will make sure to apply as little updates as possible, and also remove missing values w/o breaking reactivity + reconcile({ + ...props, + ...slots, + children, + }), + ); + } else { + const [store, setStore] = createStore({ + ...props, + ...slots, + children, + }); + // store the function to update the current mounted component + alreadyInitializedElements.set(element, setStore); + + const dispose = bootstrap( + () => { + const inner = () => createComponent(Component, store); + + if (isHydrate) { + return createComponent(Suspense, { + get children() { + return inner(); + }, + }); + } else { + return inner(); + } + }, + element, + { + renderId, + }, + ); + element.addEventListener('astro:unmount', () => dispose(), { once: true }); + } + }; diff --git a/packages/integrations/solid/src/context.ts b/packages/integrations/solid/src/context.ts new file mode 100644 index 000000000..6e201e3f5 --- /dev/null +++ b/packages/integrations/solid/src/context.ts @@ -0,0 +1,28 @@ +import type { RendererContext } from './types.js'; + +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: Context = { + 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; +} diff --git a/packages/integrations/solid/src/index.ts b/packages/integrations/solid/src/index.ts new file mode 100644 index 000000000..deab40fd0 --- /dev/null +++ b/packages/integrations/solid/src/index.ts @@ -0,0 +1,125 @@ +import type { + AstroIntegration, + AstroIntegrationLogger, + AstroRenderer, + ContainerRenderer, +} from 'astro'; +import type { PluginOption, UserConfig } from 'vite'; +import solid, { type Options as ViteSolidPluginOptions } from 'vite-plugin-solid'; + +// TODO: keep in sync with https://github.com/thetarnav/solid-devtools/blob/main/packages/main/src/vite/index.ts#L7 +type DevtoolsPluginOptions = { + /** Add automatic name when creating signals, memos, stores, or mutables */ + autoname?: boolean; + locator?: + | boolean + | { + /** Choose in which IDE the component source code should be revealed. */ + targetIDE?: string; + /** + * Holding which key should enable the locator overlay? + * @default 'Alt' + */ + key?: string; + /** Inject location attributes to jsx templates */ + jsxLocation?: boolean; + /** Inject location information to component declarations */ + componentLocation?: boolean; + }; +}; +type DevtoolsPlugin = (_options?: DevtoolsPluginOptions) => PluginOption; + +async function getDevtoolsPlugin(logger: AstroIntegrationLogger, retrieve: boolean) { + if (!retrieve) { + return null; + } + + try { + // @ts-ignore + return (await import('solid-devtools/vite')).default as DevtoolsPlugin; + } catch (_) { + logger.warn( + 'Solid Devtools requires `solid-devtools` as a peer dependency, add it to your project.', + ); + return null; + } +} + +function getViteConfiguration( + { include, exclude }: Options, + devtoolsPlugin: DevtoolsPlugin | null, +) { + const config: UserConfig = { + optimizeDeps: { + include: ['@astrojs/solid-js/client.js'], + exclude: ['@astrojs/solid-js/server.js'], + }, + plugins: [solid({ include, exclude, ssr: true })], + }; + + if (devtoolsPlugin) { + config.plugins?.push(devtoolsPlugin({ autoname: true })); + } + + return config; +} + +function getRenderer(): AstroRenderer { + return { + name: '@astrojs/solid-js', + clientEntrypoint: '@astrojs/solid-js/client.js', + serverEntrypoint: '@astrojs/solid-js/server.js', + }; +} + +export function getContainerRenderer(): ContainerRenderer { + return { + name: '@astrojs/solid', + serverEntrypoint: '@astrojs/solid-js/server.js', + }; +} + +export interface Options extends Pick<ViteSolidPluginOptions, 'include' | 'exclude'> { + devtools?: boolean; +} + +export default function (options: Options = {}): AstroIntegration { + return { + name: '@astrojs/solid-js', + hooks: { + 'astro:config:setup': async ({ + command, + addRenderer, + updateConfig, + injectScript, + logger, + }) => { + const devtoolsPlugin = await getDevtoolsPlugin( + logger, + !!options.devtools && command === 'dev', + ); + + addRenderer(getRenderer()); + updateConfig({ + vite: getViteConfiguration(options, devtoolsPlugin), + }); + + if (devtoolsPlugin) { + injectScript('page', 'import "solid-devtools";'); + } + }, + 'astro:config:done': ({ logger, config }) => { + const knownJsxRenderers = ['@astrojs/react', '@astrojs/preact', '@astrojs/solid-js']; + const enabledKnownJsxRenderers = config.integrations.filter((renderer) => + knownJsxRenderers.includes(renderer.name), + ); + + if (enabledKnownJsxRenderers.length > 1 && !options.include && !options.exclude) { + logger.warn( + 'More than one JSX renderer is enabled. This will lead to unexpected behavior unless you set the `include` or `exclude` option. See https://docs.astro.build/en/guides/integrations-guide/solid-js/#combining-multiple-jsx-frameworks for more information.', + ); + } + }, + }, + }; +} diff --git a/packages/integrations/solid/src/server.ts b/packages/integrations/solid/src/server.ts new file mode 100644 index 000000000..8d66fffe1 --- /dev/null +++ b/packages/integrations/solid/src/server.ts @@ -0,0 +1,143 @@ +import type { NamedSSRLoadedRendererValue } from 'astro'; +import { + NoHydration, + Suspense, + createComponent, + generateHydrationScript, + renderToString, + renderToStringAsync, + ssr, +} from 'solid-js/web'; +import { getContext, incrementId } from './context.js'; +import type { RendererContext } from './types.js'; + +const slotName = (str: string) => str.trim().replace(/[-_]([a-z])/g, (_, w) => w.toUpperCase()); + +type RenderStrategy = 'sync' | 'async'; + +async function check( + this: RendererContext, + Component: any, + props: Record<string, any>, + children: any, +) { + if (typeof Component !== 'function') return false; + if (Component.name === 'QwikComponent') return false; + // Svelte component renders fine by Solid as an empty string. The only way to detect + // if this isn't a Solid but Svelte component is to unfortunately copy the check + // implementation of the Svelte renderer. + if (Component.toString().includes('$$payload')) return false; + + // There is nothing particularly special about Solid components. Basically they are just functions. + // In general, components from other frameworks (eg, MDX, React, etc.) tend to render as "undefined", + // so we take advantage of this trick to decide if this is a Solid component or not. + + let html: string | undefined; + try { + const result = await renderToStaticMarkup.call(this, Component, props, children, { + // The purpose of check() is just to validate that this is a Solid component and not + // React, etc. We should render in sync mode which should skip Suspense boundaries + // or loading resources like external API calls. + renderStrategy: 'sync' as RenderStrategy, + }); + html = result.html; + } catch {} + + return typeof html === 'string'; +} + +// AsyncRendererComponentFn +async function renderToStaticMarkup( + this: RendererContext, + Component: any, + props: Record<string, any>, + { default: children, ...slotted }: any, + metadata?: Record<string, any>, +) { + const ctx = getContext(this.result); + const renderId = metadata?.hydrate ? incrementId(ctx) : ''; + const needsHydrate = metadata?.astroStaticSlot ? !!metadata.hydrate : true; + const tagName = needsHydrate ? 'astro-slot' : 'astro-static-slot'; + + const renderStrategy = (metadata?.renderStrategy ?? 'async') as RenderStrategy; + + const renderFn = () => { + const slots: Record<string, any> = {}; + for (const [key, value] of Object.entries(slotted)) { + const name = slotName(key); + slots[name] = ssr(`<${tagName} name="${name}">${value}</${tagName}>`); + } + // 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(`<${tagName}>${children}</${tagName}>`) : children, + }; + + if (renderStrategy === 'sync') { + // Sync Render: + // <Component /> + // This render mode is not exposed directly to the end user. It is only + // used in the check() function. + return createComponent(Component, newProps); + } else { + if (needsHydrate) { + // Hydrate + Async Render: + // <Suspense> + // <Component /> + // </Suspense> + return createComponent(Suspense, { + get children() { + return createComponent(Component, newProps); + }, + }); + } else { + // Static + Async Render + // <NoHydration> + // <Suspense> + // <Component /> + // </Suspense> + // </NoHydration> + return createComponent(NoHydration, { + get children() { + return createComponent(Suspense, { + get children() { + return createComponent(Component, newProps); + }, + }); + }, + }); + } + } + }; + + const componentHtml = + renderStrategy === 'async' + ? await renderToStringAsync(renderFn, { + renderId, + // New setting since Solid 1.8.4 that fixes an errant hydration event appearing in + // server only components. Not available in TypeScript types yet. + // https://github.com/solidjs/solid/issues/1931 + // https://github.com/ryansolid/dom-expressions/commit/e09e255ac725fd59195aa0f3918065d4bd974e6b + ...({ noScripts: !needsHydrate } as any), + }) + : renderToString(renderFn, { renderId }); + + return { + attrs: { + 'data-solid-render-id': renderId, + }, + html: componentHtml, + }; +} + +const renderer: NamedSSRLoadedRendererValue = { + name: '@astrojs/solid', + check, + renderToStaticMarkup, + supportsAstroStaticSlot: true, + renderHydrationScript: () => generateHydrationScript(), +}; + +export default renderer; diff --git a/packages/integrations/solid/src/types.ts b/packages/integrations/solid/src/types.ts new file mode 100644 index 000000000..5dff5b0b4 --- /dev/null +++ b/packages/integrations/solid/src/types.ts @@ -0,0 +1,4 @@ +import type { SSRResult } from 'astro'; +export type RendererContext = { + result: SSRResult; +}; |