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
18 changes: 18 additions & 0 deletions .changeset/deep-solid2-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@solid-primitives/deep": major
---

Migrate to Solid.js v2.0 (beta.13)

## Breaking Changes

**Peer dependencies**: `solid-js@^2.0.0-beta.13` and `@solidjs/web@^2.0.0-beta.13` are now required.

### `@solid-primitives/deep`

- `isServer` now imported from `@solidjs/web` (not `solid-js/web`)
- Store imports (`createStore`, `reconcile`, `snapshot`) moved from `solid-js/store` to `solid-js`
- `unwrap` replaced by `snapshot` — returns a plain non-reactive copy of a store
- Store setters now use draft-first form: `setState(s => { s.prop = value; })` replaces path-based setters
- `createEffect` in examples updated to required split compute/apply form
- `trackStore` now correctly handles getters (subscribes to reactive deps accessed through getters) and rejects plain object wrappers around stores (`pojo: false` behavior preserved)
71 changes: 41 additions & 30 deletions packages/deep/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,20 +41,23 @@ import { trackDeep } from "@solid-primitives/deep";

const [state, setState] = createStore({ name: "John", age: 42 });

createEffect(() => {
trackDeep(state);
/* execute some logic whenever the state changes */
});
createEffect(
() => trackDeep(state),
() => {
/* execute some logic whenever the state changes */
}
);
```

Or since this has a composable design, you can create _derivative_ functions and use them similar to derivative signals.

```ts
const deeplyTrackedStore = () => trackDeep(sign);
createEffect(() => {
console.log("Store is: ", deeplyTrackedStore());
// ^ this causes a re-execution of the effect on deep changes of properties
});
createEffect(
() => deeplyTrackedStore(),
// ^ this causes a re-execution of the effect on deep changes of properties
value => console.log("Store is:", value)
);
```

`trackDeep` will traverse any "wrappable" object _(objects that solid stores will wrap with proxies)_, even if it's not a solid store.
Expand All @@ -66,15 +69,17 @@ createEffect(() => {
});
```

> **Warning** If you `unwrap` a store, it will no longer be tracked by `trackDeep` nor `trackStore`!
> **Warning** If you `snapshot` a store, it will no longer be tracked by `trackDeep` nor `trackStore`!

```ts
const unwrapped = unwrap(state);
import { snapshot } from "solid-js";

createEffect(() => {
// This will NOT work:
trackDeep(unwrapped);
});
const plain = snapshot(state);

createEffect(
() => trackDeep(plain), // This will NOT work — plain objects are not reactive
() => {}
);
```

## `trackStore`
Expand All @@ -92,10 +97,12 @@ import { trackStore } from "@solid-primitives/deep";

const [state, setState] = createStore({ name: "John", age: 42 });

createEffect(() => {
trackStore(state);
/* execute some logic whenever the state changes */
});
createEffect(
() => trackStore(state),
() => {
/* execute some logic whenever the state changes */
}
);
```

## `captureStoreUpdates`
Expand All @@ -115,7 +122,7 @@ const getDelta = captureStoreUpdates(state);

getDelta(); // [{ path: [], value: { todos: [] } }]

setState("todos", ["foo"]);
setState(s => { s.todos = ["foo"]; });

getDelta(); // [{ path: ["todos"], value: ["foo"] }]
```
Expand All @@ -127,11 +134,13 @@ const [state, setState] = createStore({ todos: [] });

const getDelta = captureStoreUpdates(state);

createEffect(() => {
const delta = getDelta();
/* execute some logic whenever the state changes */
console.log(delta);
});
createEffect(
() => getDelta(),
delta => {
/* execute some logic whenever the state changes */
console.log(delta);
}
);
```

The returned function is not a signal - it won't get updated by itself, it has to be called manually, or under a tracking scope to capture new updates.
Expand All @@ -144,12 +153,14 @@ const [state, setState] = createStore({ todos: [] });
const delta = createMemo(captureStoreUpdates(state));

// both of these effects will receive the same delta
createEffect(() => {
console.log(delta());
});
createEffect(() => {
console.log(delta());
});
createEffect(
() => delta(),
value => console.log(value)
);
createEffect(
() => delta(),
value => console.log(value)
);
```

### Demo
Expand Down
6 changes: 4 additions & 2 deletions packages/deep/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,11 @@
"@solid-primitives/memo": "workspace:^"
},
"peerDependencies": {
"solid-js": "^1.6.12"
"@solidjs/web": "^2.0.0-beta.13",
"solid-js": "^2.0.0-beta.13"
},
"devDependencies": {
"solid-js": "^1.9.7"
"@solidjs/web": "2.0.0-beta.13",
"solid-js": "2.0.0-beta.13"
}
}
69 changes: 53 additions & 16 deletions packages/deep/src/store-updates.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { createLazyMemo } from "@solid-primitives/memo";
import { $PROXY, $TRACK, type Accessor, createRoot, untrack } from "solid-js";
import { unwrap } from "solid-js/store";
import { isDev, isServer } from "solid-js/web";
import { $PROXY, $TRACK, type Accessor, DEV, runWithOwner, untrack, snapshot } from "solid-js";
import { isServer } from "@solidjs/web";

// Indexable by string or number keys — matches what Solid stores can wrap.
type Static<T = unknown> = { [K in number | string]: T } | T[];

// Typed iterator that preserves numeric keys for arrays vs. string keys for objects.
// Object.entries() returns [string, T][] even for arrays, losing the numeric key type,
// so arrays are iterated manually.
function* entries<T extends Static>(
obj: T,
): IterableIterator<T extends any[] ? [number, T[number]] : [keyof T, T[keyof T]]> {
Expand All @@ -19,27 +22,41 @@ function* entries<T extends Static>(
}
}

// A store node is any object that Solid has wrapped with a reactive proxy ($TRACK marks it).
type StoreNode = Record<typeof $TRACK, unknown> & Static;
// The set of direct store-node children of a given node (non-store leaf values are excluded).
type StoreNodeChildren = Static<StoreNode>;

// One lazy memo per store node, keyed by node identity. The memo re-runs whenever the node's
// structure changes ([$TRACK] subscription) and returns the current set of child store nodes.
// Detached from any owner so it lives as long as the node is reachable, then self-disposes.
const StoreNodeChildrenCache = new WeakMap<StoreNode, Accessor<StoreNodeChildren>>();

// Returns the reactive snapshot of a node's direct store-node children.
// Uses a lazy memo so the computation is only created once per node, and only runs when read.
function getStoreNodechildren(node: StoreNode): StoreNodeChildren {
let signal = StoreNodeChildrenCache.get(node);

if (!signal) {
const unwrapped = unwrap(node),
isArray = Array.isArray(unwrapped);
const isArray = Array.isArray(node);

signal = createRoot(() =>
// runWithOwner(null) detaches the memo from any current reactive owner so it won't be
// disposed when a caller's effect re-runs. It self-disposes when it has no subscribers.
signal = runWithOwner(null, () =>
createLazyMemo(() => {
// Subscribe to structural changes (key additions/removals) on this node.
node[$TRACK];
// snapshot() inside untrack() gives us the plain key list without subscribing to
// individual property signals — we only want to know which keys exist, not their values.
const unwrapped = untrack(() => snapshot(node));
const children: StoreNodeChildren = isArray ? [] : {};
for (const [key, child] of entries(unwrapped)) {
let childNode: any;
if (
child != null &&
typeof child === "object" &&
// Prefer the proxy stored on the plain value ($PROXY), falling back to reading the
// key through the live store proxy (which re-wraps nested objects on access).
((childNode = (child as any)[$PROXY]) ||
((childNode = untrack(() => node[key as any])) && $TRACK in childNode))
) {
Expand All @@ -63,45 +80,60 @@ export type NestedUpdate<T> = {
value: AllNestedObjects<T>;
};

// Per-node cache entry: the children snapshot from the last getDelta() call, plus a
// recursively-shaped record mirroring the same structure so we can walk the tree.
type StoreUpdateCache = {
[K in string | number]: {
children: StoreNodeChildren;
record: StoreUpdateCache;
};
};

// Module-level globals, reset at the start of every getDelta() call.
// Safe because JS is single-threaded — no two calls can interleave.
let CurrentUpdates!: NestedUpdate<any>[];
let SeenNodes!: WeakSet<StoreNode>;

// Builds a fresh cache entry for a node that was added or changed.
// Recursively pre-populates children so future calls can diff them.
function newCacheNode(children: StoreNodeChildren): StoreUpdateCache[number] {
const record: StoreUpdateCache = { ...children } as any;

for (const [key, node] of entries(children)) {
if (SeenNodes.has(node)) continue;
if (SeenNodes.has(node)) continue; // guard against circular references
SeenNodes.add(node);
record[key] = newCacheNode(getStoreNodechildren(node));
}

return { children, record };
}

// Walks the store tree, diffing each node against its cached snapshot.
// A node is "changed" when its children reference differs from the cached one —
// getStoreNodechildren() returns a stable reference when nothing has changed,
// so a strict equality check is sufficient and cheap.
// When a change is found, the whole subtree is re-cached and reported as a single
// update at the shallowest changed node (so leaf changes inside an object are reported
// as one update on the parent object, not one per leaf).
function compareStoreWithCache(
node: StoreNode,
parent: StoreUpdateCache,
key: string | number,
path: (string | number)[],
): void {
if (SeenNodes.has(node)) return;
if (SeenNodes.has(node)) return; // guard against circular references
SeenNodes.add(node);

const cacheNode = parent[key],
children = getStoreNodechildren(node);

if (cacheNode && children === cacheNode.children) {
// Node itself is unchanged; recurse to check its children.
for (const [key, child] of entries(children)) {
compareStoreWithCache(child, cacheNode.record, key, [...path, key]);
}
} else {
// Node is new or its structure changed — record it and rebuild its subtree cache.
parent[key] = newCacheNode(children);
CurrentUpdates.push({ path, value: node });
}
Expand All @@ -125,28 +157,33 @@ function compareStoreWithCache(
*
* getDelta(); // [{ path: [], value: { todos: [] } }]
*
* setState("todos", ["foo"]);
* setState(s => { s.todos = ["foo"]; });
*
* getDelta(); // [{ path: ["todos"], value: ["foo"] }]
* ```
*/
export function captureStoreUpdates<T extends Static>(store: T): () => NestedUpdate<T>[] {
// on the server you cannot check if the passed object is a store
// so we just return the whole store always
if (isServer || !($TRACK in store)) {
if (isDev) {
// On the server $TRACK is not present on store proxies, so we can't diff.
// Return the whole store on the first call and nothing thereafter.
if (isServer) {
let init = true;
return () => (init ? ((init = false), [{ path: [], value: store as any }]) : []);
}

if (!(typeof store === "object" && store !== null && $TRACK in store)) {
if (DEV) {
// eslint-disable-next-line no-console
console.warn("createStoreDelta expects a store, got", store);
console.warn("captureStoreUpdates expects a store, got", store);
}

let init = true;
return () => (init ? ((init = false), [{ path: [], value: store as any }]) : []);
}

// The root cache entry — "root" is a synthetic key so compareStoreWithCache can write
// cache[key] uniformly without special-casing the top level.
const cache: StoreUpdateCache = {};

return () => {
// set globals before each cycle
CurrentUpdates = [];
SeenNodes = new WeakSet();

Expand Down
11 changes: 5 additions & 6 deletions packages/deep/src/track-deep.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { $PROXY } from "solid-js";
import { type Store } from "solid-js/store";
import { $PROXY, type Store } from "solid-js";

/**
* Tracks all properties of a {@link store} by iterating over them recursively.
Expand All @@ -11,23 +10,23 @@ import { type Store } from "solid-js/store";
*
* @example
* ```ts
* createEffect(on(
* createEffect(
* () => trackDeep(store),
* () => {
* // this effect will run when any property of store changes
* }
* ));
* );
* ```
*/
function trackDeep<T extends Store<object>>(store: T): T {
traverse(store, new Set());
return store;
}

function traverse<T>(value: Store<T>, seen: Set<unknown>): void {
function traverse(value: unknown, seen: Set<unknown>): void {
let isArray: boolean;
let proto;
// check the same conditions as in `isWrappable` from `/packages/solid/store/src/store.ts`
// check the same conditions as in `isWrappable` from solid's store implementation
if (
value != null &&
typeof value === "object" &&
Expand Down
Loading