Skip to content
Merged
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
17 changes: 17 additions & 0 deletions .changeset/solid-two-integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@codebelt/classy-store': minor
---

Migrate the `/solid` adapter to Solid 2.0.0-rc.18 only. Solid 1 is no longer
supported. Pin the exact release candidate; browser apps should also use
`@solidjs/web@2.0.0-rc.18` and Solid 2's compiler tooling. The public
`useClassyStore(store): () => Snapshot<T>` API is unchanged.

Forward store notifications synchronously to Solid while preserving Solid 2's
native scheduling and ordinary core subscription batching. Solid's `flush()`
now applies pending store-backed computed values and DOM updates.

Require a live Solid owner before subscribing, and release subscriptions on owner
disposal, including disposal during notification. Add real-component regressions
for inputs, computed values, keyed lists, timing, and cleanup; update the example,
TypeScript checks, CI, and public integration guide.
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ jobs:
# Packed-consumer typechecks resolve the package's generated declarations.
- run: bun run build
- run: bun run typecheck
- name: Solid 2 example production build
run: bun run --filter classy-store-solid-example build
- name: Use Ubuntu archive mirror for browser dependencies
run: |
# Avoid slow Azure mirror downloads on hosted runners.
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,19 @@ For independent forms, nested editors, and request-scoped state, see the [typed

Most components only need `useClassyStore(store)` to render application data. Use [`useHistoryStatus(history)`](https://codebelt.github.io/classy-store/docs/#usehistorystatushistory) for undo/redo controls and recording status, or [`usePersistStatus(handle)`](https://codebelt.github.io/classy-store/docs/#usepersiststatushandle) for hydration, expiry, and persistence errors. Both hooks come from `@codebelt/classy-store/react` and update even when store data is unchanged. Manual `save()`, `clear()`, and `rehydrate()` calls reject on failure after reporting through status and `onError`. Handle these promises in event handlers; automatic persistence reports failures without producing unhandled rejections.

## Solid 2 integration

The `/solid` adapter targets **Solid 2.0.0-rc.18 only**; Solid 1 is no longer
supported. Pin `solid-js` and the browser renderer `@solidjs/web` to that exact
release candidate. `useClassyStore(store)` returns an immutable snapshot accessor
and requires a live component or `createRoot()` owner for automatic cleanup.

Store changes reach Solid synchronously, while Solid 2 batches its reactive
updates. Use Solid's `flush()` when imperative code needs updated computed values
or DOM immediately. Ordinary core subscriptions remain batched. See the
[Solid 2 guide](https://codebelt.github.io/classy-store/docs/SOLID) for inputs,
keyed lists, ownership, and migration of the example toolchain.

## 📦 Installation

```bash
Expand Down
12 changes: 12 additions & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,18 @@
}
},
"overrides": [
{
"includes": [
"**/frameworks/solid/**",
"**/tests/browser/solid/**",
"examples/solid/**"
],
"linter": {
"rules": {
"correctness": {"useHookAtTopLevel": "off"}
}
}
},
{
"includes": ["**/*.vue", "**/*.svelte"],
"linter": {
Expand Down
691 changes: 535 additions & 156 deletions bun.lock

Large diffs are not rendered by default.

13 changes: 8 additions & 5 deletions examples/solid/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,19 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"build": "bun run typecheck && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"check:integration": "tsc --project ../../packages/classy-store/tests/browser/solid/tsconfig.json --noEmit"
},
"dependencies": {
"@codebelt/classy-store": "workspace:*",
"solid-js": "^1.9.0"
"@solidjs/web": "2.0.0-rc.18",
"solid-js": "2.0.0-rc.18"
},
"devDependencies": {
"@solidjs/vite-plugin": "3.0.0-next.51",
"typescript": "^5.7.0",
"vite": "^6.0.0",
"vite-plugin-solid": "^2.0.0"
"vite": "8.3.5"
}
}
2 changes: 1 addition & 1 deletion examples/solid/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {render} from 'solid-js/web';
import {render} from '@solidjs/web';
import {App} from './App';

const root = document.getElementById('root');
Expand Down
2 changes: 1 addition & 1 deletion examples/solid/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"jsxImportSource": "@solidjs/web",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
Expand Down
8 changes: 4 additions & 4 deletions examples/solid/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import path from 'node:path';
import solidPlugin from '@solidjs/vite-plugin';
import {defineConfig} from 'vite';
import solidPlugin from 'vite-plugin-solid';

export default defineConfig({
plugins: [solidPlugin()],
Expand All @@ -9,21 +9,21 @@ export default defineConfig({
{
find: '@codebelt/classy-store/solid',
replacement: path.resolve(
__dirname,
import.meta.dirname,
'../../packages/classy-store/src/frameworks/solid/solid.ts',
),
},
{
find: '@codebelt/classy-store/utils',
replacement: path.resolve(
__dirname,
import.meta.dirname,
'../../packages/classy-store/src/utils/index.ts',
),
},
{
find: '@codebelt/classy-store',
replacement: path.resolve(
__dirname,
import.meta.dirname,
'../../packages/classy-store/src/index.ts',
),
},
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"---------- Lint --------------------------------------------------------": "",
"lint": "biome check",
"lint:fix": "biome check --write",
"typecheck": "bun run --filter @codebelt/classy-store typecheck && bun run --filter @codebelt/classy-store typecheck:scoping && bun run --filter classy-store-svelte-example check:integration && bun run --filter classy-store-vue-example check:integration",
"typecheck": "bun run --filter @codebelt/classy-store typecheck && bun run --filter @codebelt/classy-store typecheck:scoping && bun run --filter classy-store-svelte-example check:integration && bun run --filter classy-store-vue-example check:integration && bun run --filter classy-store-solid-example typecheck && bun run --filter classy-store-solid-example check:integration",
"---------- Release (Changesets) ----------------------------------------": "",
"changeset": "changeset",
"changeset:add": "changeset add",
Expand Down
6 changes: 4 additions & 2 deletions packages/classy-store/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@
"vue": ">=3.2.0",
"svelte": ">=4.0.0",
"@angular/core": ">=17.0.0",
"solid-js": ">=1.0.0"
"solid-js": "2.0.0-rc.18"
},
"peerDependenciesMeta": {
"react": {
Expand All @@ -157,14 +157,16 @@
"@babel/core": "7.29.7",
"@happy-dom/global-registrator": "21.0.4",
"@playwright/test": "1.64.1",
"@solidjs/compiler": "2.0.0-rc.18",
"@solidjs/web": "2.0.0-rc.18",
"@types/bun": "1.4.3",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"babel-plugin-react-compiler": "1.0.0",
"react": "19.3.1",
"react-compiler-runtime": "1.0.0",
"react-dom": "19.3.1",
"solid-js": "1.9.17",
"solid-js": "2.0.0-rc.18",
"svelte": "5.55.5",
"tsdown": "0.23.1",
"typescript": "7.0.2",
Expand Down
135 changes: 98 additions & 37 deletions packages/classy-store/src/frameworks/solid/solid.test.ts
Original file line number Diff line number Diff line change
@@ -1,56 +1,117 @@
import {describe, expect, it} from 'bun:test';
import {createRoot} from 'solid-js';
import {createClassyStore} from '../../core/core';
import {createRoot, getOwner, runWithOwner} from 'solid-js';
import {createClassyStore, getInternal, subscribe} from '../../core/core';
import {snapshot} from '../../snapshot/snapshot';
import {useClassyStore} from './solid';

const flush = () => new Promise<void>((resolve) => setTimeout(resolve, 0));

// Bun resolves Solid's server runtime. Client scheduling is tested in Playwright.
describe('solid useClassyStore', () => {
it('throws when given a non-store object', () => {
createRoot((dispose) => {
expect(() => useClassyStore({count: 0})).toThrow(/not a store proxy/);
it('validates the store before checking ownership', () => {
expect(() => useClassyStore({count: 0})).toThrow(/not a store proxy/);
});

it('rejects calls without an owner before subscribing', () => {
const store = createClassyStore({count: 0});
expect(() => useClassyStore(store)).toThrow(/active Solid owner/);
expect(getInternal(store).syncListeners.size).toBe(0);
expect(getInternal(store).batchedListeners.size).toBe(0);
});

it('rejects a disposed owner before subscribing', () => {
const store = createClassyStore({count: 0});
const owner = createRoot((dispose) => {
const current = getOwner();
dispose();
return current;
});
runWithOwner(owner, () => {
expect(() => useClassyStore(store)).toThrow(/active Solid owner/);
});
expect(getInternal(store).syncListeners.size).toBe(0);
expect(getInternal(store).batchedListeners.size).toBe(0);
});

it('returns a signal accessor with the current snapshot', () => {
const s = createClassyStore({count: 5});
it('returns the current immutable snapshot without copying it', () => {
const store = createClassyStore({count: 5, nested: {label: 'Initial'}});
createRoot((dispose) => {
const get = useClassyStore(s);
expect(get()).toEqual({count: 5});
dispose();
try {
const state = useClassyStore(store);
expect(state()).toBe(snapshot(store));
expect(Object.isFrozen(state())).toBe(true);
expect(Object.isFrozen(state().nested)).toBe(true);
} finally {
dispose();
}
});
});

it('signal updates when the store mutates', async () => {
const s = createClassyStore({count: 0});
let getter!: () => unknown;
let dispose!: () => void;
createRoot((d) => {
getter = useClassyStore(s);
dispose = d;
it('cleans up independent roots and reads fresh state on remount', async () => {
const store = createClassyStore({count: 0});
const first = createRoot((dispose) => ({
state: useClassyStore(store),
dispose,
}));
const second = createRoot((dispose) => ({
state: useClassyStore(store),
dispose,
}));
try {
store.count = 1;
await Promise.resolve();
first.dispose();
first.dispose();
store.count = 2;
await Promise.resolve();
expect(first.state().count).toBe(1);
expect(second.state().count).toBe(2);
} finally {
first.dispose();
second.dispose();
}
expect(getInternal(store).syncListeners.size).toBe(0);
expect(getInternal(store).batchedListeners.size).toBe(0);
createRoot((dispose) => {
try {
expect(useClassyStore(store)().count).toBe(2);
} finally {
dispose();
}
});

s.count = 7;
await flush();
expect(getter()).toEqual({count: 7});
dispose();
});

it('cleans up subscription on root dispose', async () => {
const s = createClassyStore({count: 0});
let getter!: () => unknown;
let dispose!: () => void;
createRoot((d) => {
getter = useClassyStore(s);
dispose = d;
it('disposes subscriptions in nested roots with their parent', () => {
const store = createClassyStore({count: 0});
const dispose = createRoot((stopParent) => {
useClassyStore(store);
createRoot(() => useClassyStore(store));
return stopParent;
});
try {
expect(getInternal(store).syncListeners.size).toBe(2);
} finally {
dispose();
}
expect(getInternal(store).syncListeners.size).toBe(0);
expect(getInternal(store).batchedListeners.size).toBe(0);
});

const before = getter();
dispose();

s.count = 999;
await flush();
expect(getter()).toBe(before);
it('ignores delivery after disposal during an in-progress notification', async () => {
const store = createClassyStore({count: 0});
let dispose = () => {};
const stop = subscribe(store, () => dispose(), {sync: true});
const state = createRoot((stopRoot) => {
dispose = stopRoot;
return useClassyStore(store);
});
try {
store.count = 1;
await Promise.resolve();
expect(state().count).toBe(0);
} finally {
dispose();
stop();
}
expect(getInternal(store).syncListeners.size).toBe(0);
expect(getInternal(store).batchedListeners.size).toBe(0);
});
});
38 changes: 31 additions & 7 deletions packages/classy-store/src/frameworks/solid/solid.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,45 @@
import {createSignal, onCleanup} from 'solid-js';
import {createSignal, getOwner, isDisposed, onCleanup} from 'solid-js';
import {getInternal, subscribe} from '../../core/core';
import {snapshot} from '../../snapshot/snapshot';
import type {Snapshot} from '../../types';

/**
* Read immutable snapshots inside a Solid 2 component or live reactive root.
* Store notifications reach Solid synchronously; Solid schedules its own updates.
* The owner disposes the subscription. Use Solid's flush() for imperative reads.
*/
export function useClassyStore<T extends object>(
proxyStore: T,
): () => Snapshot<T> {
// Validate up front so users get a clear error instead of an opaque trace.
getInternal(proxyStore);
const owner = getOwner();
if (!owner || isDisposed(owner)) {
throw new Error(
'@codebelt/classy-store/solid: useClassyStore() requires an active Solid owner; call it in a component or createRoot()',
);
}

const [state, setState] = createSignal<Snapshot<T>>(snapshot(proxyStore));
// Snapshot construction returns an object/array; select the plain-value overload.
const [state, setState] = createSignal<Snapshot<T>>(
// biome-ignore lint/complexity/noBannedTypes: Matches Solid's exclusion of all callable initializers.
snapshot(proxyStore) as Exclude<Snapshot<T>, Function>,
{ownedWrite: true},
);
let active = true;

const unsubscribe = subscribe(proxyStore, () => {
setState(() => snapshot(proxyStore));
});
const unsubscribe = subscribe(
proxyStore,
() => {
// Another listener can dispose this owner during the same notification.
if (active && !isDisposed(owner)) setState(() => snapshot(proxyStore));
},
{sync: true},
);

onCleanup(unsubscribe);
onCleanup(() => {
active = false;
unsubscribe();
});

return state;
}
Loading
Loading