diff --git a/.changeset/fresh-bobcats-report.md b/.changeset/fresh-bobcats-report.md
new file mode 100644
index 000000000000..9c5a7c16886f
--- /dev/null
+++ b/.changeset/fresh-bobcats-report.md
@@ -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);
+---
+
+
+```
+
+Signals can also be passed inside arrays and objects. Setters are supported as props alongside getters.
diff --git a/packages/astro/e2e/fixtures/solid-signals/astro.config.mjs b/packages/astro/e2e/fixtures/solid-signals/astro.config.mjs
new file mode 100644
index 000000000000..dcd5306e9485
--- /dev/null
+++ b/packages/astro/e2e/fixtures/solid-signals/astro.config.mjs
@@ -0,0 +1,6 @@
+import solid from '@astrojs/solid-js';
+import { defineConfig } from 'astro/config';
+
+export default defineConfig({
+ integrations: [solid()],
+});
diff --git a/packages/astro/e2e/fixtures/solid-signals/package.json b/packages/astro/e2e/fixtures/solid-signals/package.json
new file mode 100644
index 000000000000..7812222e6ec0
--- /dev/null
+++ b/packages/astro/e2e/fixtures/solid-signals/package.json
@@ -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"
+ }
+}
diff --git a/packages/astro/e2e/fixtures/solid-signals/src/components/Counter.jsx b/packages/astro/e2e/fixtures/solid-signals/src/components/Counter.jsx
new file mode 100644
index 000000000000..91035562f7d5
--- /dev/null
+++ b/packages/astro/e2e/fixtures/solid-signals/src/components/Counter.jsx
@@ -0,0 +1,13 @@
+export default function Counter(props) {
+ return (
+
+
+
{props.count()}
+
+
+ );
+}
diff --git a/packages/astro/e2e/fixtures/solid-signals/src/components/Display.jsx b/packages/astro/e2e/fixtures/solid-signals/src/components/Display.jsx
new file mode 100644
index 000000000000..631b2e40cbab
--- /dev/null
+++ b/packages/astro/e2e/fixtures/solid-signals/src/components/Display.jsx
@@ -0,0 +1,7 @@
+export default function Display(props) {
+ return (
+
+ {props.count()}
+
+ );
+}
diff --git a/packages/astro/e2e/fixtures/solid-signals/src/pages/index.astro b/packages/astro/e2e/fixtures/solid-signals/src/pages/index.astro
new file mode 100644
index 000000000000..31120bb1deaa
--- /dev/null
+++ b/packages/astro/e2e/fixtures/solid-signals/src/pages/index.astro
@@ -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);
+---
+
+
+ Solid Signals E2E
+
+
+
+
+
+
diff --git a/packages/astro/e2e/solid-signals.test.ts b/packages/astro/e2e/solid-signals.test.ts
new file mode 100644
index 000000000000..1750f69981dc
--- /dev/null
+++ b/packages/astro/e2e/solid-signals.test.ts
@@ -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');
+ });
+});
diff --git a/packages/integrations/solid/package.json b/packages/integrations/solid/package.json
index 8bd8ca25eb28..b334add0fa2b 100644
--- a/packages/integrations/solid/package.json
+++ b/packages/integrations/solid/package.json
@@ -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",
@@ -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",
@@ -41,6 +43,7 @@
"devDependencies": {
"astro": "workspace:*",
"astro-scripts": "workspace:*",
+ "cheerio": "1.2.0",
"solid-js": "^1.9.13"
},
"peerDependencies": {
diff --git a/packages/integrations/solid/src/client.ts b/packages/integrations/solid/src/client.ts
index 94261e0ec5eb..8f501951b6e9 100644
--- a/packages/integrations/solid/src/client.ts
+++ b/packages/integrations/solid/src/client.ts
@@ -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();
const alreadyInitializedElements = new WeakMap();
+// 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) {
+ const signalsRaw = element.dataset.solidSignals;
+ if (!signalsRaw) return;
+
+ const signals: Record = 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;
@@ -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)!(
diff --git a/packages/integrations/solid/src/context.ts b/packages/integrations/solid/src/context.ts
index 6e201e3f5534..fe84d980ddb8 100644
--- a/packages/integrations/solid/src/context.ts
+++ b/packages/integrations/solid/src/context.ts
@@ -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, string>;
+ propsToSignals: Map, PropNameToSignalMap>;
};
const contexts = new WeakMap();
@@ -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;
@@ -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;
+}
diff --git a/packages/integrations/solid/src/island-signal.ts b/packages/integrations/solid/src/island-signal.ts
new file mode 100644
index 000000000000..d23f58bfb3d2
--- /dev/null
+++ b/packages/integrations/solid/src/island-signal.ts
@@ -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) & {
+ [ISLAND_SIGNAL]: true;
+ [ISLAND_SIGNAL_PEEK]: () => T;
+};
+
+export type IslandSetter = ((v: T | ((prev: T) => T)) => T) & {
+ [ISLAND_SETTER_FOR]: IslandAccessor;
+};
+
+/**
+ * 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);
+ * ---
+ *
+ *
+ * ```
+ */
+export function createIslandSignal(value: T): [IslandAccessor, IslandSetter] {
+ const [get, set] = createSignal(value);
+
+ const accessor = get as IslandAccessor;
+ accessor[ISLAND_SIGNAL] = true;
+ accessor[ISLAND_SIGNAL_PEEK] = () => get();
+
+ const setter = set as unknown as IslandSetter;
+ setter[ISLAND_SETTER_FOR] = accessor;
+
+ return [accessor, setter];
+}
+
+export function isIslandSignal(x: any): x is IslandAccessor {
+ return typeof x === 'function' && x[ISLAND_SIGNAL] === true;
+}
+
+export function isIslandSetter(x: any): x is IslandSetter {
+ return typeof x === 'function' && x[ISLAND_SETTER_FOR] != null;
+}
+
+export function peekIslandSignal(x: IslandAccessor): T {
+ return x[ISLAND_SIGNAL_PEEK]();
+}
+
+export function getSetterAccessor(x: IslandSetter): IslandAccessor {
+ return x[ISLAND_SETTER_FOR];
+}
diff --git a/packages/integrations/solid/src/server.ts b/packages/integrations/solid/src/server.ts
index e6390fffe924..f72f7ee78b67 100644
--- a/packages/integrations/solid/src/server.ts
+++ b/packages/integrations/solid/src/server.ts
@@ -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());
@@ -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 = {};
for (const [key, value] of Object.entries(slotted)) {
@@ -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,
diff --git a/packages/integrations/solid/src/signals.ts b/packages/integrations/solid/src/signals.ts
new file mode 100644
index 000000000000..ff99cce3fee2
--- /dev/null
+++ b/packages/integrations/solid/src/signals.ts
@@ -0,0 +1,144 @@
+import type { Context } from './context.js';
+import { incrementSignalId } from './context.js';
+import {
+ type IslandAccessor,
+ getSetterAccessor,
+ isIslandSetter,
+ isIslandSignal,
+ peekIslandSignal,
+} from './island-signal.js';
+import type {
+ ArrayObjectMapping,
+ AstroSolidAttrs,
+ PropNameToSignalMap,
+ Signals,
+ SignalToKeyOrIndexMap,
+} from './types.js';
+
+export function restoreSignalsOnProps(ctx: Context, props: Record) {
+ let propMap: PropNameToSignalMap;
+ if (ctx.propsToSignals.has(props)) {
+ propMap = ctx.propsToSignals.get(props)!;
+ } else {
+ propMap = new Map();
+ ctx.propsToSignals.set(props, propMap);
+ }
+ for (const [key, signal] of propMap) {
+ props[key] = signal;
+ }
+ return propMap;
+}
+
+// Returns the accessor for the signal, whether the prop is a getter or setter.
+function getAccessorForProp(value: any): IslandAccessor | null {
+ if (isIslandSignal(value)) return value;
+ if (isIslandSetter(value)) return getSetterAccessor(value);
+ return null;
+}
+
+// Signal IDs use a "!" suffix to indicate a setter prop, e.g. "sg0" = getter, "sg0!" = setter.
+function encodeSignalRef(id: string, isSetter: boolean): string {
+ return isSetter ? id + '!' : id;
+}
+
+/**
+ * Detects island signals in props, builds the serialization mapping for `data-solid-signals`,
+ * and records what needs to be restored. Does NOT mutate props yet — Solid components need
+ * the signal getters to remain callable during SSR. Call `replaceSignalsWithValues` after
+ * rendering to prepare props for Astro's `serializeProps`.
+ */
+export function serializeSignals(
+ ctx: Context,
+ props: Record,
+ attrs: AstroSolidAttrs,
+ map: PropNameToSignalMap,
+) {
+ const signals: Signals = {};
+ for (const [key, value] of Object.entries(props)) {
+ const isPropArray = Array.isArray(value);
+ const isPropObject =
+ !isIslandSignal(value) &&
+ !isIslandSetter(value) &&
+ typeof value === 'object' &&
+ value !== null &&
+ !isPropArray;
+
+ if (isPropObject || isPropArray) {
+ const values = isPropObject ? Object.keys(value) : value;
+ values.forEach((valueKey: number | string, valueIndex: number) => {
+ const item = isPropObject ? props[key][valueKey] : valueKey;
+ const accessor = getAccessorForProp(item);
+ if (accessor) {
+ const keyOrIndex = isPropObject ? valueKey.toString() : valueIndex;
+ const isSetter = isIslandSetter(item);
+
+ const currentMap = (map.get(key) || []) as SignalToKeyOrIndexMap;
+ map.set(key, [...currentMap, [item, keyOrIndex]]);
+
+ const currentSignals = (signals[key] || []) as ArrayObjectMapping;
+ signals[key] = [
+ ...currentSignals,
+ [encodeSignalRef(getSignalId(ctx, accessor), isSetter), keyOrIndex],
+ ];
+ }
+ });
+ } else {
+ const accessor = getAccessorForProp(value);
+ if (accessor) {
+ const isSetter = isIslandSetter(value);
+ map.set(key, value);
+ signals[key] = encodeSignalRef(getSignalId(ctx, accessor), isSetter);
+ }
+ }
+ }
+
+ if (Object.keys(signals).length) {
+ attrs['data-solid-signals'] = JSON.stringify(signals);
+ }
+}
+
+/**
+ * Replaces island signal getters/setters on props with their peeked scalar values.
+ * Call this AFTER SSR rendering so that Astro's `serializeProps` gets plain values.
+ */
+export function replaceSignalsWithValues(props: Record) {
+ for (const [key, value] of Object.entries(props)) {
+ const isPropArray = Array.isArray(value);
+ const isPropObject =
+ !isIslandSignal(value) &&
+ !isIslandSetter(value) &&
+ typeof value === 'object' &&
+ value !== null &&
+ !isPropArray;
+
+ if (isPropObject || isPropArray) {
+ const values = isPropObject ? Object.keys(value) : value;
+ values.forEach((valueKey: number | string, valueIndex: number) => {
+ const item = isPropObject ? props[key][valueKey] : valueKey;
+ const accessor = getAccessorForProp(item);
+ if (accessor) {
+ const keyOrIndex = isPropObject ? valueKey.toString() : valueIndex;
+ props[key] = isPropObject
+ ? Object.assign({}, props[key], { [keyOrIndex]: peekIslandSignal(accessor) })
+ : props[key].map((v: any, i: number) =>
+ i === valueIndex ? [peekIslandSignal(accessor), i] : v,
+ );
+ }
+ });
+ } else {
+ const accessor = getAccessorForProp(value);
+ if (accessor) {
+ props[key] = peekIslandSignal(accessor);
+ }
+ }
+ }
+}
+
+function getSignalId(ctx: Context, accessor: IslandAccessor) {
+ let id = ctx.signals.get(accessor);
+ if (!id) {
+ id = incrementSignalId(ctx);
+ ctx.signals.set(accessor, id);
+ }
+ return id;
+}
diff --git a/packages/integrations/solid/src/types.ts b/packages/integrations/solid/src/types.ts
index 5dff5b0b4e21..1c896713a687 100644
--- a/packages/integrations/solid/src/types.ts
+++ b/packages/integrations/solid/src/types.ts
@@ -2,3 +2,17 @@ import type { SSRResult } from 'astro';
export type RendererContext = {
result: SSRResult;
};
+
+// A branded island signal accessor or setter
+export type IslandSignalLike = (...args: any[]) => any;
+
+export type ArrayObjectMapping = [string, number | string][];
+export type Signals = Record;
+
+export type SignalToKeyOrIndexMap = [IslandSignalLike, number | string][];
+export type PropNameToSignalMap = Map;
+
+export type AstroSolidAttrs = {
+ ['data-solid-render-id']?: string;
+ ['data-solid-signals']?: string;
+};
diff --git a/packages/integrations/solid/test/fixtures/signals/astro.config.mjs b/packages/integrations/solid/test/fixtures/signals/astro.config.mjs
new file mode 100644
index 000000000000..dcd5306e9485
--- /dev/null
+++ b/packages/integrations/solid/test/fixtures/signals/astro.config.mjs
@@ -0,0 +1,6 @@
+import solid from '@astrojs/solid-js';
+import { defineConfig } from 'astro/config';
+
+export default defineConfig({
+ integrations: [solid()],
+});
diff --git a/packages/integrations/solid/test/fixtures/signals/package.json b/packages/integrations/solid/test/fixtures/signals/package.json
new file mode 100644
index 000000000000..9d17622692f3
--- /dev/null
+++ b/packages/integrations/solid/test/fixtures/signals/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "@test/solid-signals",
+ "version": "0.0.0",
+ "private": true,
+ "dependencies": {
+ "@astrojs/solid-js": "workspace:*",
+ "astro": "workspace:*",
+ "solid-js": "^1.9.13"
+ }
+}
diff --git a/packages/integrations/solid/test/fixtures/signals/src/components/SignalCounter.jsx b/packages/integrations/solid/test/fixtures/signals/src/components/SignalCounter.jsx
new file mode 100644
index 000000000000..98e10e750b76
--- /dev/null
+++ b/packages/integrations/solid/test/fixtures/signals/src/components/SignalCounter.jsx
@@ -0,0 +1,3 @@
+export default function SignalCounter(props) {
+ return
{props.count()}
;
+}
diff --git a/packages/integrations/solid/test/fixtures/signals/src/components/SignalsInArray.jsx b/packages/integrations/solid/test/fixtures/signals/src/components/SignalsInArray.jsx
new file mode 100644
index 000000000000..020a478bea42
--- /dev/null
+++ b/packages/integrations/solid/test/fixtures/signals/src/components/SignalsInArray.jsx
@@ -0,0 +1,12 @@
+export default function SignalsInArray(props) {
+ return (
+