Skip to content
Open
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
20 changes: 20 additions & 0 deletions .changeset/fresh-bobcats-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@astrojs/solid-js': minor
---

Adds `createIslandSignal` for sharing reactive signals across multiple Solid islands

A new `createIslandSignal` function, importable from `@astrojs/solid-js/signals`, lets you create a signal in `.astro` frontmatter and pass it to multiple `client:*` Solid components. All islands that receive the same signal share a single reactive instance on the client, so updating the signal in one island automatically updates the others.

```astro
---
import { createIslandSignal } from '@astrojs/solid-js/signals';
import Counter from '../components/Counter';
import Display from '../components/Display';
const [count, setCount] = createIslandSignal(0);
---
<Counter count={count} setCount={setCount} client:load />
<Display count={count} client:load />
```

Signals can also be passed inside arrays and objects. Setters are supported as props alongside getters.
6 changes: 6 additions & 0 deletions packages/astro/e2e/fixtures/solid-signals/astro.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import solid from '@astrojs/solid-js';
import { defineConfig } from 'astro/config';

export default defineConfig({
integrations: [solid()],
});
10 changes: 10 additions & 0 deletions packages/astro/e2e/fixtures/solid-signals/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"name": "@e2e/solid-signals",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/solid-js": "workspace:*",
"astro": "workspace:*",
"solid-js": "^1.9.13"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export default function Counter(props) {
return (
<div id={props.id} class="counter">
<button class="decrement" onClick={() => props.setCount((v) => v - 1)}>
-
</button>
<pre>{props.count()}</pre>
<button class="increment" onClick={() => props.setCount((v) => v + 1)}>
+
</button>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default function Display(props) {
return (
<div id={props.id} class="display">
<span class="value">{props.count()}</span>
</div>
);
}
15 changes: 15 additions & 0 deletions packages/astro/e2e/fixtures/solid-signals/src/pages/index.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
import { createIslandSignal } from '@astrojs/solid-js/signals';
import Counter from '../components/Counter';
import Display from '../components/Display';
const [count, setCount] = createIslandSignal(10);
---
<html>
<head>
<title>Solid Signals E2E</title>
</head>
<body>
<Counter id="counter" count={count} setCount={setCount} client:load />
<Display id="display" count={count} client:load />
</body>
</html>
62 changes: 62 additions & 0 deletions packages/astro/e2e/solid-signals.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { expect } from '@playwright/test';
import { type DevServer, testFactory, waitForHydrate } from './test-utils.ts';

const test = testFactory(import.meta.url, { root: './fixtures/solid-signals/' });

let devServer: DevServer;

test.beforeAll(async ({ astro }) => {
devServer = await astro.startDevServer();
});

test.afterAll(async () => {
await devServer.stop();
});

test.describe('Solid island signals', () => {
test('signal hydrates with correct initial value', async ({ astro, page }) => {
await page.goto(astro.resolveUrl('/'));

const counter = page.locator('#counter');
await expect(counter, 'counter is visible').toBeVisible();

const display = page.locator('#display');
await expect(display, 'display is visible').toBeVisible();

// SSR renders the initial value
await expect(counter.locator('pre'), 'counter initial value is 10').toHaveText('10');
await expect(display.locator('.value'), 'display initial value is 10').toHaveText('10');

// Wait for both islands to hydrate
await waitForHydrate(page, counter);
await waitForHydrate(page, display);

// Values should still be correct after hydration
await expect(counter.locator('pre'), 'counter value after hydration is 10').toHaveText('10');
await expect(display.locator('.value'), 'display value after hydration is 10').toHaveText('10');
});

test('shared signal updates across islands', async ({ astro, page }) => {
await page.goto(astro.resolveUrl('/'));

const counter = page.locator('#counter');
const display = page.locator('#display');

await waitForHydrate(page, counter);
await waitForHydrate(page, display);

// Click increment in the counter island
await counter.locator('.increment').click();

// Both islands should reflect the updated value
await expect(counter.locator('pre'), 'counter incremented to 11').toHaveText('11');
await expect(display.locator('.value'), 'display updated to 11').toHaveText('11');

// Click decrement twice
await counter.locator('.decrement').click();
await counter.locator('.decrement').click();

await expect(counter.locator('pre'), 'counter decremented to 9').toHaveText('9');
await expect(display.locator('.value'), 'display updated to 9').toHaveText('9');
});
});
5 changes: 4 additions & 1 deletion packages/integrations/solid/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"homepage": "https://docs.astro.build/en/guides/integrations-guide/solid-js/",
"exports": {
".": "./dist/index.js",
"./signals": "./dist/island-signal.js",
"./container-renderer": "./dist/container-renderer.js",
"./client.js": "./dist/client.js",
"./server.js": "./dist/server.js",
Expand All @@ -32,7 +33,8 @@
"scripts": {
"build": "astro-scripts build \"src/**/*.ts\" && tsc -b",
"build:ci": "astro-scripts build \"src/**/*.ts\"",
"dev": "astro-scripts dev \"src/**/*.ts\""
"dev": "astro-scripts dev \"src/**/*.ts\"",
"test": "astro-scripts test \"test/**/*.test.ts\""
},
"dependencies": {
"vite": "^8.0.13",
Expand All @@ -41,6 +43,7 @@
"devDependencies": {
"astro": "workspace:*",
"astro-scripts": "workspace:*",
"cheerio": "1.2.0",
"solid-js": "^1.9.13"
},
"peerDependencies": {
Expand Down
55 changes: 54 additions & 1 deletion packages/integrations/solid/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,58 @@
import { Suspense } from 'solid-js';
import { createSignal, Suspense } from 'solid-js';
import { createStore, reconcile } from 'solid-js/store';
import { createComponent, hydrate, render } from 'solid-js/web';

type SignalTuple = [() => any, (v: any) => any];
const sharedSignalMap = new Map<string, SignalTuple>();
const alreadyInitializedElements = new WeakMap<Element, any>();

// Signal IDs ending with "!" indicate a setter prop, e.g. "sg0!" = setter, "sg0" = getter.
function decodeSignalRef(ref: string): { id: string; isSetter: boolean } {
if (ref.endsWith('!')) {
return { id: ref.slice(0, -1), isSetter: true };
}
return { id: ref, isSetter: false };
}

function getSignalPart(tuple: SignalTuple, isSetter: boolean): any {
return isSetter ? tuple[1] : tuple[0];
}

function restoreSignals(element: HTMLElement, props: Record<string, any>) {
const signalsRaw = element.dataset.solidSignals;
if (!signalsRaw) return;

const signals: Record<string, string | [string, number | string][]> = JSON.parse(signalsRaw);
for (const [propName, signalId] of Object.entries(signals)) {
if (Array.isArray(signalId)) {
// Array or object case: signalId is [ref, indexOrKey][]
signalId.forEach(([ref, indexOrKeyInProps]) => {
const { id, isSetter } = decodeSignalRef(ref);
const mapValue = props[propName][indexOrKeyInProps];
let valueOfSignal = mapValue;

// For arrays, the serialized form is [value, originalIndex]
if (typeof indexOrKeyInProps !== 'string') {
valueOfSignal = mapValue[0];
indexOrKeyInProps = mapValue[1];
}

if (!sharedSignalMap.has(id)) {
sharedSignalMap.set(id, createSignal(valueOfSignal));
}
props[propName][indexOrKeyInProps] = getSignalPart(sharedSignalMap.get(id)!, isSetter);
});
} else {
// Direct signal prop
const { id, isSetter } = decodeSignalRef(signalId);
if (!sharedSignalMap.has(id)) {
sharedSignalMap.set(id, createSignal(props[propName]));
}
props[propName] = getSignalPart(sharedSignalMap.get(id)!, isSetter);
}
}
}

export default (element: HTMLElement) =>
(Component: any, props: any, slotted: any, { client }: { client: string }) => {
if (!element.hasAttribute('ssr')) return;
Expand Down Expand Up @@ -33,6 +82,10 @@ export default (element: HTMLElement) =>

const { default: children, ...slots } = _slots;
const renderId = element.dataset.solidRenderId;

// Restore shared island signals onto props before creating the store
restoreSignals(element, props);

if (alreadyInitializedElements.has(element)) {
// update the mounted component
alreadyInitializedElements.get(element)!(
Expand Down
21 changes: 19 additions & 2 deletions packages/integrations/solid/src/context.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import type { RendererContext } from './types.js';
import type { IslandAccessor } from './island-signal.js';
import type { PropNameToSignalMap, RendererContext } from './types.js';

type Context = {
export type Context = {
id: string;
c: number;
signalId: string;
sc: number;
signals: Map<IslandAccessor<any>, string>;
propsToSignals: Map<Record<string, any>, PropNameToSignalMap>;
};

const contexts = new WeakMap<RendererContext['result'], Context>();
Expand All @@ -16,6 +21,12 @@ export function getContext(result: RendererContext['result']): Context {
get id() {
return 's' + this.c.toString();
},
sc: 0,
get signalId() {
return 'sg' + this.sc.toString();
},
signals: new Map(),
propsToSignals: new Map(),
};
contexts.set(result, ctx);
return ctx;
Expand All @@ -26,3 +37,9 @@ export function incrementId(ctx: Context): string {
ctx.c++;
return id;
}

export function incrementSignalId(ctx: Context): string {
let id = ctx.signalId;
ctx.sc++;
return id;
}
56 changes: 56 additions & 0 deletions packages/integrations/solid/src/island-signal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { createSignal } from 'solid-js';

const ISLAND_SIGNAL = Symbol.for('astro.solid.island-signal');
const ISLAND_SIGNAL_PEEK = Symbol.for('astro.solid.island-signal.peek');
const ISLAND_SETTER_FOR = Symbol.for('astro.solid.island-signal.setter-for');

export type IslandAccessor<T> = (() => T) & {
[ISLAND_SIGNAL]: true;
[ISLAND_SIGNAL_PEEK]: () => T;
};

export type IslandSetter<T> = ((v: T | ((prev: T) => T)) => T) & {
[ISLAND_SETTER_FOR]: IslandAccessor<T>;
};

/**
* Creates a signal that can be shared across multiple Solid islands.
* Use this in `.astro` frontmatter to pass reactive state to `client:*` components.
*
* ```astro
* ---
* import { createIslandSignal } from '@astrojs/solid-js/signals';
* const [count, setCount] = createIslandSignal(0);
* ---
* <Counter count={count} client:load />
* <Display count={count} client:load />
* ```
*/
export function createIslandSignal<T>(value: T): [IslandAccessor<T>, IslandSetter<T>] {
const [get, set] = createSignal(value);

const accessor = get as IslandAccessor<T>;
accessor[ISLAND_SIGNAL] = true;
accessor[ISLAND_SIGNAL_PEEK] = () => get();

const setter = set as unknown as IslandSetter<T>;
setter[ISLAND_SETTER_FOR] = accessor;

return [accessor, setter];
}

export function isIslandSignal(x: any): x is IslandAccessor<any> {
return typeof x === 'function' && x[ISLAND_SIGNAL] === true;
}

export function isIslandSetter(x: any): x is IslandSetter<any> {
return typeof x === 'function' && x[ISLAND_SETTER_FOR] != null;
}

export function peekIslandSignal<T>(x: IslandAccessor<T>): T {
return x[ISLAND_SIGNAL_PEEK]();
}

export function getSetterAccessor<T>(x: IslandSetter<T>): IslandAccessor<T> {
return x[ISLAND_SETTER_FOR];
}
14 changes: 13 additions & 1 deletion packages/integrations/solid/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import {
ssr,
} from 'solid-js/web';
import { getContext, incrementId } from './context.js';
import type { RendererContext } from './types.js';
import { replaceSignalsWithValues, restoreSignalsOnProps, serializeSignals } from './signals.js';
import type { AstroSolidAttrs, RendererContext } from './types.js';

const slotName = (str: string) => str.trim().replace(/[-_]([a-z])/g, (_, w) => w.toUpperCase());

Expand Down Expand Up @@ -61,6 +62,12 @@ async function renderToStaticMarkup(

const renderStrategy = (metadata?.renderStrategy ?? 'async') as RenderStrategy;

// Restore signals back onto props so that they will be passed as-is to components
let propsMap = restoreSignalsOnProps(ctx, props);

const attrs: AstroSolidAttrs = {};
serializeSignals(ctx, props, attrs, propsMap);

const renderFn = () => {
const slots: Record<string, any> = {};
for (const [key, value] of Object.entries(slotted)) {
Expand Down Expand Up @@ -124,8 +131,13 @@ async function renderToStaticMarkup(
})
: renderToString(renderFn, { renderId });

// Replace signal getters/setters with scalar values so that Astro's serializeProps
// serializes plain values (functions would be dropped by JSON.stringify).
replaceSignalsWithValues(props);

return {
attrs: {
...attrs,
'data-solid-render-id': renderId,
},
html: componentHtml,
Expand Down
Loading
Loading