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

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

## Breaking Changes

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

### `isServer` import source

`isServer` is now sourced from `@solidjs/web` instead of `solid-js/web` (handled internally — no consumer change needed).

### `createScheduled` — `getListener` renamed to `getObserver`

Uses `getObserver` from `solid-js` internally (Solid 2.0 rename of `getListener`). No consumer API change.

### `createScheduled` — `ownedWrite: true` on internal signal

The internal invalidation signal now uses `{ ownedWrite: true }` to allow synchronous writes from within reactive computation scopes. This is required when using `leading`-edge schedules, which fire the invalidation callback synchronously from inside an effect's compute phase.

### `createScheduled` with `createEffect` — two-arg form required

In Solid 2.0, `createEffect` requires a compute function and a separate apply function. The `scheduled()` accessor should be called in the compute phase:

```ts
// ✅ Solid 2.0
createEffect(
() => { const value = count(); const dirty = scheduled(); return { value, dirty }; },
({ value, dirty }) => { if (dirty) console.log("count", value); },
);

// ❌ Solid 1.x (no longer works)
createEffect(() => {
const value = count();
if (scheduled()) console.log("count", value);
});
```

`createScheduled` continues to work with `createMemo` unchanged.
22 changes: 14 additions & 8 deletions packages/scheduled/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,14 +157,20 @@ const scheduled = createScheduled(fn => debounce(fn, 1000));

const [count, setCount] = createSignal(0);

createEffect(() => {
// track source signal
const value = count();
// track the debounced signal and check if it's dirty
if (scheduled()) {
console.log("count", value);
}
});
// In Solid 2.0, use the two-arg createEffect: compute (tracked) + apply (side effects)
createEffect(
() => {
// compute: track both source signal and the scheduled signal
const value = count();
const dirty = scheduled();
return { value, dirty };
},
({ value, dirty }) => {
if (dirty) {
console.log("count", value);
}
},
);

// or with createMemo
const debouncedCount = createMemo((p: number = 0) => {
Expand Down
6 changes: 4 additions & 2 deletions packages/scheduled/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,12 @@
"devDependencies": {
"@solid-primitives/event-listener": "workspace:^",
"@solid-primitives/timer": "workspace:^",
"solid-js": "^1.9.7"
"@solidjs/web": "2.0.0-beta.10",
"solid-js": "2.0.0-beta.10"
},
"peerDependencies": {
"solid-js": "^1.6.12"
"@solidjs/web": "^2.0.0-beta.10",
"solid-js": "^2.0.0-beta.10"
},
"typesVersions": {}
}
25 changes: 14 additions & 11 deletions packages/scheduled/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type Accessor, createSignal, getListener, getOwner, onCleanup } from "solid-js";
import { isServer } from "solid-js/web";
import { type Accessor, createSignal, getObserver, getOwner, onCleanup } from "solid-js";
import { isServer } from "@solidjs/web";

export type ScheduleCallback = <Args extends unknown[]>(
callback: (...args: Args) => void,
Expand Down Expand Up @@ -269,14 +269,15 @@ export function leadingAndTrailing<Args extends unknown[]>(
* ```ts
* const debounced = createScheduled(fn => debounce(fn, 250));
*
* createEffect(() => {
* // track source signal
* const value = count();
* // track the debounced signal and check if it's dirty
* if (debounced()) {
* console.log('count', value);
* createEffect(
* () => count(), // compute: track source signal
* value => {
* // apply: runs after scheduled invalidation
* if (debounced()) {
* console.log('count', value);
* }
* }
* });
* );
* ```
*/

Expand All @@ -287,7 +288,9 @@ export function createScheduled(
): Accessor<boolean> {
let listeners = 0;
let isDirty = false;
const [track, dirty] = createSignal(void 0, { equals: false });
// ownedWrite: true allows dirty() to be called synchronously from within a
// reactive computation's compute phase (e.g. when using leading edge schedules).
const [track, dirty] = createSignal(void 0, { equals: false, ownedWrite: true });
const call = schedule(() => {
isDirty = true;
dirty();
Expand All @@ -300,7 +303,7 @@ export function createScheduled(
return true;
}

if (getListener()) {
if (getObserver()) {
listeners++;
onCleanup(() => listeners--);
}
Expand Down
167 changes: 104 additions & 63 deletions packages/scheduled/test/create-scheduled.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createComputed, createEffect, createRoot, createSignal } from "solid-js";
import { createEffect, createRoot, createSignal, flush } from "solid-js";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createScheduled, debounce, leading } from "../src/index.js";

Expand Down Expand Up @@ -33,85 +33,104 @@ describe("createScheduled", () => {
invalidate = fn;
return () => {};
});
let i = 0;
const [track, trigger] = createSignal(undefined, { equals: false });
createComputed(() => {
i++;
track();
expect(scheduled()).toBe(i === 3);
if (i === 1) return trigger();
if (i === 2) return invalidate();
dispose();
});
const vals: boolean[] = [];
// ownedWrite: true because trigger() is called inside the createRoot scope
const [track, trigger] = createSignal(undefined, { equals: false, ownedWrite: true });

createEffect(
() => {
track();
return scheduled();
},
val => {
vals.push(val);
},
);

flush(); // run 1: subscribed, not yet dirty
expect(vals).toEqual([false]);

trigger();
flush(); // run 2: re-run due to user signal, call() is noop here
expect(vals).toEqual([false, false]);

invalidate(); // sets isDirty + writes track signal
flush(); // run 3: dirty → returns true
expect(vals).toEqual([false, false, true]);

dispose();
});
});

it("debounces", () => {
const [track, trigger] = createSignal(undefined, { equals: false });

let i = 0;
let value: boolean | undefined;
const vals: boolean[] = [];

const dispose = createRoot(dispose => {
const scheduled = createScheduled(fn => debounce(fn, 20));

createEffect(() => {
track();
i++;
value = scheduled();
});
createEffect(
() => {
track();
return scheduled();
},
val => {
vals.push(val);
},
);

return dispose;
});

expect(value).toBe(false);
expect(i).toBe(1);
flush(); // initial run
expect(vals).toEqual([false]);

trigger();
flush(); // re-run, debounce timer started
expect(vals).toEqual([false, false]);

expect(value).toBe(false);
expect(i).toBe(2);

vi.advanceTimersByTime(50);

expect(value).toBe(true);
expect(i).toBe(3);
vi.advanceTimersByTime(50); // debounce fires → isDirty=true, dirty()
flush(); // re-run after invalidation
expect(vals).toEqual([false, false, true]);

if (i === 1) return trigger();
if (i === 2) return;
dispose();
});

it("debounces with leading", () => {
const [track, trigger] = createSignal(undefined, { equals: false });

let i = 0;
let value: boolean | undefined;
const vals: boolean[] = [];

const dispose = createRoot(dispose => {
const scheduled = createScheduled(fn => leading(debounce, fn, 20));

createEffect(() => {
track();
i++;
value = scheduled();
});
createEffect(
() => {
track();
return scheduled();
},
val => {
vals.push(val);
},
);

return dispose;
});

expect(value).toBe(true);
expect(i).toBe(1);
// Leading fires fn() synchronously in Run 1: isDirty=true → returns true, isDirty=false.
// dirty() write is deferred until the next flush (Solid 2.0 prevents same-flush loops).
flush();
expect(vals).toEqual([true]);

// trigger() causes a new flush. The effect re-runs: leading already scheduled (isScheduled=true)
// so fn is NOT re-fired → returns false.
trigger();
flush();
expect(vals).toEqual([true, false]);

expect(value).toBe(false);
expect(i).toBe(2);

// The debounce trailing edge resets isScheduled but does NOT call fn → no dirty() call.
vi.advanceTimersByTime(50);

expect(value).toBe(false);
expect(i).toBe(2);
flush();
expect(vals).toEqual([true, false]);

dispose();
});
Expand All @@ -123,23 +142,45 @@ describe("createScheduled", () => {
invalidate = fn;
return () => {};
});
let i = 0;
const [track, trigger] = createSignal(undefined, { equals: false });
createComputed(() => {
i++;
track();
expect(scheduled(), `(a) run ${i}`).toBe(i === 3);
if (i === 1) return;
if (i === 2) return;
});
let j = 0;
createComputed(() => {
j++;
track();
expect(scheduled(), `(b) run ${j}`).toBe(j === 3);
if (j === 1) return trigger();
if (j === 2) return invalidate();
});
const vals_a: boolean[] = [];
const vals_b: boolean[] = [];
// ownedWrite: true because trigger() is called inside the createRoot scope
const [track, trigger] = createSignal(undefined, { equals: false, ownedWrite: true });

createEffect(
() => {
track();
return scheduled();
},
val => {
vals_a.push(val);
},
);

createEffect(
() => {
track();
return scheduled();
},
val => {
vals_b.push(val);
},
);

flush(); // initial runs
expect(vals_a).toEqual([false]);
expect(vals_b).toEqual([false]);

trigger();
flush(); // re-runs after user trigger
expect(vals_a).toEqual([false, false]);
expect(vals_b).toEqual([false, false]);

invalidate();
flush(); // both re-run after invalidation
expect(vals_a).toEqual([false, false, true]);
expect(vals_b).toEqual([false, false, true]);

dispose();
});
});
Expand Down
7 changes: 5 additions & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.