diff --git a/.changeset/eleven-buttons-jump.md b/.changeset/eleven-buttons-jump.md new file mode 100644 index 000000000..d8b5cc1f9 --- /dev/null +++ b/.changeset/eleven-buttons-jump.md @@ -0,0 +1,5 @@ +--- +"@solid-primitives/storage": major +--- + +**`makePersisted`** - simplify setter, Solid 2.0 adaption, simpler types using function overloads diff --git a/packages/storage/dev/index.tsx b/packages/storage/dev/index.tsx deleted file mode 100644 index 284a830bf..000000000 --- a/packages/storage/dev/index.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { type Component, createSignal, untrack } from "solid-js"; -import { makePersisted } from "../src/index.js"; - -const App: Component = () => { - const [value, setValue] = makePersisted(createSignal("initial value"), { name: "value" }); - - return ( -
-
-

Persisted signal in localStorage

- setValue(ev.currentTarget.value)} /> -

Try to change the value and reload the page - it is persisted in localStorage.

-
-
- ); -}; - -export default App; diff --git a/packages/storage/package.json b/packages/storage/package.json index ce5e835a8..d2818a07b 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -64,7 +64,6 @@ } }, "scripts": { - "dev": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/dev.ts", "build": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/build.ts", "vitest": "vitest -c ../../configs/vitest.config.ts", "test": "pnpm run vitest", @@ -82,17 +81,16 @@ }, "peerDependencies": { "@tauri-apps/plugin-store": "*", - "solid-js": "^1.6.12" + "@solidjs/web": "^2.0.0-beta.14", + "solid-js": "^2.0.0-beta.14" }, "peerDependenciesMeta": { - "solid-start": { - "optional": true - }, "@tauri-apps/plugin-store": { "optional": true } }, "devDependencies": { - "solid-js": "^1.9.7" + "solid-js": "2.0.0-beta.14", + "@solidjs/web": "2.0.0-beta.14" } } diff --git a/packages/storage/src/cookies.ts b/packages/storage/src/cookies.ts index 0f7bd951d..6d5ef863a 100644 --- a/packages/storage/src/cookies.ts +++ b/packages/storage/src/cookies.ts @@ -1,4 +1,4 @@ -import { getRequestEvent, isServer, type RequestEvent } from "solid-js/web"; +import { getRequestEvent, isServer, type RequestEvent } from "@solidjs/web"; import { type SyncStorageWithOptions } from "./index.js"; import { addWithOptionsMethod, addClearMethod } from "./tools.js"; diff --git a/packages/storage/src/persisted.ts b/packages/storage/src/persisted.ts index a8f26a1ba..e766fb736 100644 --- a/packages/storage/src/persisted.ts +++ b/packages/storage/src/persisted.ts @@ -1,8 +1,6 @@ -import type { Accessor, Setter, Signal } from "solid-js"; -import { createUniqueId, untrack } from "solid-js"; -import { isServer, isDev } from "solid-js/web"; -import type { SetStoreFunction, Store } from "solid-js/store"; -import { reconcile } from "solid-js/store"; +import type { Signal, StoreSetter, Store } from "solid-js"; +import { action, createUniqueId, latest, untrack, reconcile, snapshot, DEV } from "solid-js"; +import { isServer } from "@solid-primitives/utils"; export type SyncStorage = { getItem: (key: string) => string | null; @@ -58,11 +56,16 @@ export type PersistenceSyncAPI = [ update: (key: string, value: string | null | undefined) => void, ]; -export type PersistenceOptions | undefined> = { +export type PersistenceOptions< + S extends Signal | [Store, StoreSetter] | readonly [Store, StoreSetter], + O extends Record | undefined, + T = S extends Signal ? T : S extends [Store, StoreSetter] ? T : never +> = { name?: string; serialize?: (data: T) => string; deserialize?: (data: string) => T; sync?: PersistenceSyncAPI; + action?: (signal: S) => Parameters[0]; } & (undefined extends O ? { storage?: SyncStorage | AsyncStorage } : { @@ -70,96 +73,110 @@ export type PersistenceOptions | undefined> = { storageOptions?: O; }); -export type SignalInput = Signal | [Store, SetStoreFunction]; - -export type SignalType = - S extends Signal ? T : S extends [Store, SetStoreFunction] ? T : never; +export type PersistedState | string | null> = S extends [any, any] ? [...S, I] : never; -export type PersistedState = - S extends Signal - ? [get: Accessor, set: Setter, init: Promise | string | null] - : S extends [Store, SetStoreFunction] - ? [get: Store, set: SetStoreFunction, init: Promise | string | null] - : never; +export type StoreTuple = [Store, StoreSetter]; /** * Persists a signal, store or similar API - * ```ts - * const [getter, setter] = makePersisted(createSignal("data"), options); - * const options = { - * storage: cookieStorage, // can be any synchronous or asynchronous storage - * storageOptions: { ... }, // for storages with options, otherwise not needed - * name: "solid-data", // optional - * serialize: (value: string) => value, // optional - * deserialize: (data: string) => data, // optional - * }; - * ``` - * Can be used with `createSignal` or `createStore`. The initial value from the storage will overwrite the initial - * value of the signal or store unless overwritten. Overwriting a signal with `null` or `undefined` will remove the - * item from the storage. + * ```ts + * const [getter, setter] = makePersisted(createSignal("data"), options); + * const options = { + * storage: cookieStorage, // can be any synchronous or asynchronous storage + * storageOptions: { ... }, // for storages with options, otherwise not needed + * name: "solid-data", // optional + * serialize: (value: string) => value, // optional + * deserialize: (data: string) => data, // optional + * action: (setter: Setter) => Setter // optional, to be put inside action + * }; + * ``` + * Can be used with `createSignal`, `createStore`, `createOptimistic`, or `createOptimisticStore` (for the latter + * two, use options.action to wrap the setter). The initial value from the storage will overwrite the initial + * value of the signal or store unless overwritten. Overwriting a signal with `null` or `undefined` will remove the + * item from the storage. * - * @param {Signal | [get: Store, set: SetStoreFunction]} signal - The signal or store to be persisted. - * @param {PersistenceOptions} options - The options for persistence. + * @param {Signal | [get: Store, set: StoreSetter]} signal - The signal or store to be persisted. + * @param {PersistenceOptions | [get: Store, set StoreSetter], O>} options - The options for persistence. * @returns {PersistedState} - The persisted signal or store. */ -export function makePersisted( - signal: S, - options?: PersistenceOptions, undefined>, -): PersistedState; -export function makePersisted>( - signal: S, - options: PersistenceOptions, O>, -): PersistedState; +export function makePersisted( + signal: Signal, + options?: PersistenceOptions, undefined>, +): PersistedState>; +export function makePersisted( + signal: StoreTuple, + options?: PersistenceOptions, undefined>, +): PersistedState>; +export function makePersisted< + T, + O extends Record +>(signal: Signal, options: PersistenceOptions, O>): PersistedState>; +export function makePersisted< + T, + O extends Record +>(signal: Signal, options: PersistenceOptions, O> & { storage: AsyncStorage | AsyncStorageWithOptions }): PersistedState, Promise | null>; +export function makePersisted< + T, + O extends Record +>(signal: StoreTuple, options: PersistenceOptions, O>): PersistedState>; export function makePersisted< - S extends SignalInput, + T, + O extends Record +>(signal: StoreTuple, options: PersistenceOptions, O> & { storage: AsyncStorage | AsyncStorageWithOptions }): PersistedState, Promise | null>; +export function makePersisted< + T, O extends Record | undefined, - T = SignalType, + S extends Signal | StoreTuple, >( signal: S, - options: PersistenceOptions = {} as PersistenceOptions, + options: PersistenceOptions = {} as PersistenceOptions, ): PersistedState { const storage = options.storage || (globalThis.localStorage as Storage | undefined); const name = options.name || `storage-${createUniqueId()}`; + const actionFn = options.action && options.action(signal); + if (actionFn) { + (signal as any)[1] = action(actionFn) as unknown as S[1]; + } if (!storage) { - return [signal[0], signal[1], null] as PersistedState; + return [signal[0], signal[1], null] as unknown as PersistedState; } const storageOptions = (options as unknown as { storageOptions: O }).storageOptions; const serialize: (data: T) => string = options.serialize || JSON.stringify.bind(JSON); const deserialize: (data: string) => T = options.deserialize || JSON.parse.bind(JSON); const init = storage.getItem(name, storageOptions); - const set = - typeof signal[0] === "function" - ? (data: string) => { - try { - const value = deserialize(data); - (signal[1] as any)(() => value); - } catch (e) { - // eslint-disable-next-line no-console - if (isDev) console.warn(e); - } + const isSignal = typeof signal[0] === "function"; + const set = isSignal + ? (data: string) => { + try { + const value = deserialize(data); + (signal[1] as any)(() => value); + } catch (e) { + // eslint-disable-next-line no-console + if (DEV) console.warn(e); + } + } + : (data: string) => { + try { + const value = deserialize(data); + (signal[1] as any)(reconcile(value, () => true)); + } catch (e) { + // eslint-disable-next-line no-console + if (DEV) console.warn(e); } - : (data: string) => { - try { - const value = deserialize(data); - (signal[1] as any)(reconcile(value)); - } catch (e) { - // eslint-disable-next-line no-console - if (isDev) console.warn(e); - } - }; + }; let unchanged = true; if (init instanceof Promise) init.then(data => unchanged && data && set(data)); else if (init) set(init); + const getter: () => T = isSignal ? (signal[0] as () => T) : () => snapshot(signal[0] as T); + if (typeof options.sync?.[0] === "function") { - const get: () => T = - typeof signal[0] === "function" ? (signal[0] as () => T) : () => signal[0] as T; options.sync[0]((data: PersistenceSyncData) => { if ( data.key !== name || (!isServer && (data.url || globalThis.location.href) !== globalThis.location.href) || - data.newValue === serialize(untrack(get)) + data.newValue === serialize(untrack(getter)) ) { return; } @@ -167,28 +184,27 @@ export function makePersisted< }); } + const persist = () => { + const next = untrack(() => latest(getter)); + if (next == null) { + storage.removeItem(name, storageOptions); + options.sync?.[1](name, null); + } else { + const serialized = serialize(next); + storage.setItem(name, serialized, storageOptions); + options.sync?.[1](name, serialized); + } + }; return [ - signal[0], - typeof signal[0] === "function" - ? (value?: T | ((prev: T) => T)) => { - const output = (signal[1] as Setter)(value as any); - const serialized: string | null | undefined = - value != null ? serialize(output) : (value as null | undefined); - options.sync?.[1](name, serialized); - if (serialized != null) storage.setItem(name, serialized, storageOptions); - else storage.removeItem(name, storageOptions); - unchanged = false; - return output; - } - : (...args: any[]) => { - (signal[1] as any)(...args); - const value = serialize(untrack(() => signal[0])); - options.sync?.[1](name, value); - storage.setItem(name, value, storageOptions); - unchanged = false; - }, + signal[0], + (value: any) => untrack(() => { + const output = signal[1](value); + persist(); + unchanged = false; + return output instanceof Promise ? output.then((result) => (persist(), result), (err) => { persist(); throw err; }) : output; + }), init, - ] as PersistedState; + ] as unknown as PersistedState; } /** @@ -214,7 +230,7 @@ export const messageSync = (channel: Window | BroadcastChannel = window): Persis }), (key, newValue) => channel.postMessage( - { key, newValue, timeStamp: +new Date(), url: location.href }, + { key, newValue, timeStamp: Date.now(), url: location.href }, location.origin, ), ]; @@ -222,7 +238,7 @@ export const messageSync = (channel: Window | BroadcastChannel = window): Persis /** * wsSync - syncronize persisted storage via web socket */ -export const wsSync = (ws: WebSocket, warnOnError: boolean = isDev): PersistenceSyncAPI => [ +export const wsSync = (ws: WebSocket, warnOnError: boolean = !!DEV): PersistenceSyncAPI => [ (subscriber: PersistenceSyncCallback) => ws.addEventListener("message", (ev: MessageEvent) => { try { @@ -241,7 +257,7 @@ export const wsSync = (ws: WebSocket, warnOnError: boolean = isDev): Persistence key, newValue, timeStamp: +new Date(), - ...(isServer ? {} : { url: location.href }), + ...(globalThis.window ? { url: location.href } : {}), }), ), ]; diff --git a/packages/storage/stories/storage.stories.tsx b/packages/storage/stories/storage.stories.tsx new file mode 100644 index 000000000..65930b802 --- /dev/null +++ b/packages/storage/stories/storage.stories.tsx @@ -0,0 +1,195 @@ +import { createSignal, createStore, latest, untrack } from "solid-js"; +import preview from "../../../.storybook/preview.js"; +import { makePersisted, cookieStorage } from "../src/index.js"; +import readme from "../README.md?raw"; +import { + Button, + ButtonRow, + Code, + Container, + Section, + StatRow, + TextField, + ValueDisplay, +} from "../../../.storybook/ui/index.js"; + +const meta = preview.meta({ + title: "Browser APIs/Storage", + tags: ["autodocs"], + parameters: { + layout: "centered", + docs: { + description: { + component: readme, + }, + }, + }, +}); + +export default meta; + +export const MakePersistedSignal = meta.story({ + name: "makePersisted — signal (localStorage)", + parameters: { + docs: { + description: { + story: + "`makePersisted(createSignal(initial), options)` wraps a signal so every write is mirrored to `localStorage` and the stored value is restored on page load. Setting the signal to `null` or `undefined` removes the key entirely. Change the value below, then reload — the input will show the last persisted value.", + }, + }, + }, + render: () => { + const [value, setValue] = makePersisted(createSignal("hello"), { + name: "sp_demo_signal", + }); + + return ( + + setValue(v)} + placeholder="type something…" + /> + +
+ + + +
+ + + + +
+ ); + }, +}); + +export const MakePersistedStore = meta.story({ + name: "makePersisted — store (localStorage)", + parameters: { + docs: { + description: { + story: + "`makePersisted` also accepts a `createStore` tuple. The entire store is serialised as JSON on every mutation and restored on load. Mutations use a function updater — the Solid 2.0 `StoreSetter` API.", + }, + }, + }, + render: () => { + const [store, setStore] = makePersisted( + createStore({ count: 0, label: "clicks" }), + { name: "sp_demo_store" }, + ); + + return ( + +
+ + + {store.count} + +
+ +
+ + + +
+ + + + +
+ ); + }, +}); + +export const MakePersistedCookies = meta.story({ + name: "makePersisted — cookieStorage", + parameters: { + docs: { + description: { + story: + "Pass `{ storage: cookieStorage }` to persist the signal in a browser cookie instead of `localStorage`. `cookieStorage` is a synchronous `Storage`-compatible adapter backed by `document.cookie`. Use `storageOptions` to set cookie attributes like `maxAge` or `path`.", + }, + }, + }, + render: () => { + const [username, setUsername] = makePersisted(createSignal(""), { + name: "sp_demo_cookie_user", + storage: cookieStorage, + storageOptions: { maxAge: 60 * 60 * 24 * 7, path: "/" }, + }); + + return ( + + setUsername(v)} + placeholder="enter username…" + /> + +
+ + + +
+ + + + +
+ ); + }, +}); + +export const CustomSerializer = meta.story({ + name: "makePersisted — custom serializer", + parameters: { + docs: { + description: { + story: + "Supply `serialize` and `deserialize` to store non-JSON-serializable values or to control the on-disk format. Here a `Date` is stored as an ISO string and restored as a `Date` object. The default serializer is `JSON.stringify` / `JSON.parse`.", + }, + }, + }, + render: () => { + const [savedAt, setSavedAt] = makePersisted(createSignal(null), { + name: "sp_demo_date", + serialize: (d: Date | null) => d?.toISOString() ?? "", + deserialize: (s: string) => (s ? new Date(s) : null), + }); + + return ( + + + +
+ + + +
+ + + + +
+ ); + }, +}); diff --git a/packages/storage/tauri-storage/package.json b/packages/storage/tauri-storage/package.json index 770e52a6f..089805ac7 100644 --- a/packages/storage/tauri-storage/package.json +++ b/packages/storage/tauri-storage/package.json @@ -13,13 +13,13 @@ "license": "MIT", "dependencies": { "solid-js": "^1.7.8", - "@tauri-apps/api": ">=2.0.0-beta.0", - "@tauri-apps/plugin-store": ">=2.0.0-beta.0" + "@tauri-apps/api": ">=2.0.0", + "@tauri-apps/plugin-store": ">=2.0.0" }, "devDependencies": { "typescript": "^5.0.2", "vite": "^5.0.0", "vite-plugin-solid": "^2.8.0", - "@tauri-apps/cli": ">=2.0.0-beta.0" + "@tauri-apps/cli": ">=2.0.0" } } diff --git a/packages/storage/test/persisted.test.ts b/packages/storage/test/persisted.test.ts index 9cbab1eb0..818ba76b8 100644 --- a/packages/storage/test/persisted.test.ts +++ b/packages/storage/test/persisted.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from "vitest"; -import { createSignal } from "solid-js"; -import { createStore } from "solid-js/store"; +import { + createEffect, + createRoot, + createSignal, + createStore, + createOptimistic, + flush, + latest, + refresh, +} from "solid-js"; import { makePersisted } from "../src/persisted.js"; import { type AsyncStorage } from "../src/index.js"; @@ -60,23 +68,47 @@ describe("makePersisted", () => { }); setSignal("persisted"); expect(mockStorage.getItem("test1")).toBe('"persisted"'); - expect(signal()).toBe("persisted"); + expect(latest(signal)).toBe("persisted"); }); + it("persists an optimistic signal", async () => { + const DataServer = { + data: "server", + get: () => Promise.resolve(DataServer.data), + set: (next: string) => new Promise((res) => setTimeout(() => res(DataServer.data = next), 50)), + }; + const [signal, setSignal] = makePersisted( + createOptimistic(() => DataServer.get()), + { + storage: mockStorage, + name: "test1", + action: ([getter, setter]) => function*(next) { + setter(next); + yield DataServer.set(next); + refresh(getter); + } + } + ); + await setSignal("persisted"); + flush(); + expect(mockStorage.getItem("test1")).toBe('"persisted"'); + expect(latest(signal)).toBe("persisted") + }) + it("reads the persisted value from a synchronous storage into the signal", () => { mockStorage.setItem("test2", '"persistence"'); const [signal] = makePersisted(createSignal(), { storage: mockStorage, name: "test2" }); - expect(signal()).toBe("persistence"); + expect(latest(signal)).toBe("persistence"); }); it("removes a nulled signal's storage item", () => { - const [signal, setSignal] = makePersisted(createSignal(), { + const [signal, setSignal] = makePersisted(createSignal(), { storage: mockStorage, name: "test3", }); setSignal("test"); expect(mockStorage.getItem("test3")).toBe('"test"'); - expect(signal()).toBe("test"); + expect(latest(signal)).toBe("test"); setSignal(undefined); expect(mockStorage.getItem("test3")).toBeNull(); }); @@ -86,10 +118,31 @@ describe("makePersisted", () => { storage: mockStorage, name: "test4", }); - setStore("test", "persisted"); + setStore((s) => { s.test = "persisted" }); + flush(); expect(store.test).toBe("persisted"); expect(mockStorage.getItem("test4")).toBe(JSON.stringify({ test: "persisted" })); }); + + it("only updates the leaves in initialization", async () => { + mockAsyncStorage.setItem("test4a", JSON.stringify({ a: { b: { c: 1 } } })); + let triggered = { a: 0, b: 0, c: 0 }; + await createRoot(async (dispose) => { + const [state, setState] = createStore({ a: { b: { c: 0 } } }); + createEffect(() => state.a, (_) => { triggered.a++; }); + createEffect(() => state.a.b, (_) => { triggered.b++; }); + createEffect(() => state.a.b.c, (_) => { triggered.c++; }); + const [store, setStore, init] = makePersisted( + [state, setState], + { storage: mockAsyncStorage, name: "test4a" } + ); + await new Promise(r => setTimeout(r, 50)); + flush(); + expect(store).toEqual({ a: { b: { c: 1 } } }); + expect(triggered).toEqual({ a: 1, b: 1, c: 2 }); + queueMicrotask(dispose);; + }); + }); it("persists a signal in an async storage", async () => { const [signal, setSignal] = makePersisted(createSignal(), { @@ -97,7 +150,7 @@ describe("makePersisted", () => { name: "test5", }); setSignal("async"); - expect(signal()).toBe("async"); + expect(latest(signal)).toBe("async"); expect(await mockAsyncStorage.getItem("test5")).toBe('"async"'); }); @@ -108,7 +161,7 @@ describe("makePersisted", () => { name: "test6", }); await Promise.resolve(); - expect(signal()).toBe("predefined"); + expect(latest(signal)).toBe("predefined"); setSignal("overwritten"); await Promise.resolve(); expect(await mockAsyncStorage.getItem("test6")).toBe('"overwritten"'); @@ -125,15 +178,15 @@ describe("makePersisted", () => { storage: slowMockAsyncStorage, name: "test7", }); - expect(signal()).toBe("init"); + expect(latest(signal)).toBe("init"); setSignal("overwritten"); resolve("persisted"); - expect(signal()).toBe("overwritten"); + expect(latest(signal)).toBe("overwritten"); }); it("exposes the initial value as third part of the return tuple", () => { const anotherMockAsyncStorage = { ...mockAsyncStorage }; - const promise = Promise.resolve("init"); + const promise = Promise.resolve('"init"'); anotherMockAsyncStorage.getItem = () => promise; const [_signal, _setSignal, init] = makePersisted(createSignal("default"), { storage: anotherMockAsyncStorage, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11f54dc3c..78922ba24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1165,9 +1165,12 @@ importers: specifier: '*' version: 2.0.0 devDependencies: + '@solidjs/web': + specifier: 2.0.0-beta.14 + version: 2.0.0-beta.14(solid-js@2.0.0-beta.14) solid-js: - specifier: ^1.9.7 - version: 1.9.7 + specifier: 2.0.0-beta.14 + version: 2.0.0-beta.14 packages/styles: dependencies: @@ -3035,6 +3038,10 @@ packages: '@oxc-project/types@0.127.0': resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} + '@oxc-project/types@0.130.0': + resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==} + + '@oxc-resolver/binding-android-arm-eabi@11.20.0': resolution: {integrity: sha512-IjfWOXRgJFNdORDl+Uf1aibNgZY2guOD3zmOhx1BGVb/MIiqlFTdmjpQNplSN58lhWehnX4UNqC3QwpUo8pjJg==} cpu: [arm] @@ -3473,6 +3480,9 @@ packages: peerDependencies: solid-js: '>=1.8.4' + '@solidjs/signals@2.0.0-beta.13': + resolution: {integrity: sha512-jc+wLRK+eyUFerH8Mjed4HikdJwz3z95TT7tqyG+K00IV9jfgZWvR1nZDUEZc6kXffmtW/z+w6PNn62ea5KjIw==} + '@solidjs/signals@2.0.0-beta.14': resolution: {integrity: sha512-y72nYtD7ogwX/UR5g2Y+meyeO6Q/xbQGtmvVTQX6USkMwEGOMnytqDnHj5amUzD7Fzqg32svwtCSx/q8hsOXAA==} @@ -8746,6 +8756,9 @@ snapshots: '@oxc-project/types@0.127.0': {} + '@oxc-project/types@0.130.0': {} + + '@oxc-resolver/binding-android-arm-eabi@11.20.0': optional: true @@ -9083,6 +9096,8 @@ snapshots: dependencies: solid-js: 2.0.0-beta.14 + '@solidjs/signals@2.0.0-beta.13': {} + '@solidjs/signals@2.0.0-beta.14': {} '@solidjs/web@2.0.0-beta.10(solid-js@2.0.0-beta.10)': @@ -12505,7 +12520,7 @@ snapshots: solid-js@2.0.0-beta.10: dependencies: - '@solidjs/signals': 2.0.0-beta.14 + '@solidjs/signals': 2.0.0-beta.13 csstype: 3.1.3 seroval: 1.5.2 seroval-plugins: 1.5.2(seroval@1.5.2) diff --git a/site/src/routes/package/$name/-components/primitive-name-tooltips.tsx~next b/site/src/routes/package/$name/-components/primitive-name-tooltips.tsx~next new file mode 100644 index 000000000..0812e6be3 --- /dev/null +++ b/site/src/routes/package/$name/-components/primitive-name-tooltips.tsx~next @@ -0,0 +1,24 @@ +import { type Accessor, createEffect, untrack, createSignal } from "solid-js"; +import type { BundlesizeItem } from "~/types.js"; + +export function createPrimitiveNameTooltips( + target: HTMLElement, + primitives: Accessor, +) { + const [fn, setFn] = + createSignal(); + + createEffect(() => { + const fnValue = fn(); + if (fnValue) { + createEffect(() => { + const primitivesList = primitives(); + if (primitivesList) untrack(() => fnValue({ target, primitives: primitivesList })); + }); + } + }); + + import("./primitive-name-tooltip-impl.jsx").then(({ createPrimitiveNameTooltips }) => + setFn(() => createPrimitiveNameTooltips), + ); +} diff --git a/site/src/routes/package/$name/-components/primitive-name-tooltips.tsx~port to tanstack start v1 b/site/src/routes/package/$name/-components/primitive-name-tooltips.tsx~port to tanstack start v1 new file mode 100644 index 000000000..3a33219a1 --- /dev/null +++ b/site/src/routes/package/$name/-components/primitive-name-tooltips.tsx~port to tanstack start v1 @@ -0,0 +1,26 @@ +import { type Accessor, createEffect, untrack, createSignal } from "solid-js"; +import type { BundlesizeItem } from "~/types.js"; + +export function createPrimitiveNameTooltips( + target: HTMLElement, + primitives: Accessor, +) { + const [fn, setFn] = + createSignal< + typeof import("./primitive-name-tooltip-impl.jsx").createPrimitiveNameTooltips + >(); + + createEffect(() => { + const fnValue = fn(); + if (fnValue) { + createEffect(() => { + const primitivesList = primitives(); + if (primitivesList) untrack(() => fnValue({ target, primitives: primitivesList })); + }); + } + }); + + import("./primitive-name-tooltip-impl.jsx").then(({ createPrimitiveNameTooltips }) => + setFn(() => createPrimitiveNameTooltips), + ); +} diff --git a/site/src/routes/package/$name/index.tsx~next b/site/src/routes/package/$name/index.tsx~next new file mode 100644 index 000000000..89ab73135 --- /dev/null +++ b/site/src/routes/package/$name/index.tsx~next @@ -0,0 +1,104 @@ +import { createMemo, onMount } from "solid-js"; +import { createFileRoute, notFound } from "@tanstack/solid-router"; +import { fetchPackageData } from "~/api.js"; +import NotFound from "~/components/NotFound.jsx"; +import { PRIMITIVE_PAGE_PADDING_TOP } from "~/components/Header/Header.jsx"; +import InfoBar from "~/components/Primitives/InfoBar.jsx"; +import { H2 } from "~/components/prose.jsx"; +import { pageWidthClass } from "~/constants.js"; +import { DocumentClass } from "~/primitives/document-class.jsx"; +import { kebabCaseToCapitalized } from "~/utils.js"; +import { Heading } from "./-components/heading.js"; +import { PackageInstallation } from "./-components/package-installation.js"; +import { createPrimitiveNameTooltips } from "./-components/primitive-name-tooltips.js"; + +export const Route = createFileRoute("/package/$name/")({ + component: PackagePage, + // Handle unknown package names (e.g. /package/wefwe) with the same 404 page + // as any other missing route. We throw `notFound()` from the loader when the + // dynamic JSON import fails, and a route-level `notFoundComponent` catches + // it so TanStack doesn't need to walk up to the (absent) root handler. + notFoundComponent: NotFound, + loader: async ({ params }) => { + try { + return await fetchPackageData(params.name); + } catch { + throw notFound(); + } + }, + head: ({ params, loaderData }) => ({ + meta: [ + { + title: loaderData + ? `${kebabCaseToCapitalized(params.name)} — Solid Primitives` + : "Not Found — Solid Primitives", + }, + ], + }), +}); + +function PackagePage() { + const params = Route.useParams(); + const data = Route.useLoaderData(); + + const formattedName = createMemo(() => kebabCaseToCapitalized(params().name)); + const packageName = () => `@solid-primitives/${params().name}`; + + return ( + <> + +
+
+
+
+ +
+ +
+ +
+ +
+

+ + +

+
{ + // Fade the readme in during client-side navigation. `el.isConnected` + // is false when the element is being created fresh (SPA nav) and + // true when hydrating SSR'd HTML — we only animate the former so + // the initial page load doesn't flash. + if (!el.isConnected) { + el.style.opacity = "0"; + el.style.transition = "opacity 0.3s ease-in-out"; + onMount(() => { + requestAnimationFrame(() => (el.style.opacity = "")); + }); + } + + createPrimitiveNameTooltips(el, () => data().primitives); + }} + innerHTML={data().readme} + /> +
+

+
+ + ); +} diff --git a/site/src/routes/package/$name/index.tsx~port to tanstack start v1 b/site/src/routes/package/$name/index.tsx~port to tanstack start v1 new file mode 100644 index 000000000..fba17a1ad --- /dev/null +++ b/site/src/routes/package/$name/index.tsx~port to tanstack start v1 @@ -0,0 +1,87 @@ +import { createMemo, onMount, Suspense } from "solid-js"; +import { createFileRoute } from "@tanstack/solid-router"; +import { fetchPackageData } from "~/api.js"; +import { PRIMITIVE_PAGE_PADDING_TOP } from "~/components/Header/Header.jsx"; +import InfoBar from "~/components/Primitives/InfoBar.jsx"; +import { H2 } from "~/components/prose.jsx"; +import { pageWidthClass } from "~/constants.js"; +import { DocumentClass } from "~/primitives/document-class.jsx"; +import { kebabCaseToCapitalized } from "~/utils.js"; +import { Heading } from "./-components/heading.js"; +import { PackageInstallation } from "./-components/package-installation.js"; +import { createPrimitiveNameTooltips } from "./-components/primitive-name-tooltips.js"; + +export const Route = createFileRoute("/package/$name/")({ + component: PackagePage, + loader: async ({ params }) => fetchPackageData(params.name), + head: ({ params }) => ({ + meta: [{ title: `${kebabCaseToCapitalized(params.name)} — Solid Primitives` }], + }), +}); + +function PackagePage() { + const params = Route.useParams(); + const data = Route.useLoaderData(); + + const formattedName = createMemo(() => kebabCaseToCapitalized(params().name)); + const packageName = () => `@solid-primitives/${params().name}`; + + return ( + <> + +
+
+
+
+ +
+ +
+ +
+ +
+

+ + +

+ }> +
{ + // displaying readme is under suspense, so the router doesn't wait for it to be ready + // it will be rendered after the page is already visible, + // so we fade it in, but only during client-side navigation + if (!el.isConnected) { + el.style.opacity = "0"; + el.style.transition = "opacity 0.3s ease-in-out"; + onMount(() => { + requestAnimationFrame(() => (el.style.opacity = "")); + }); + } + + createPrimitiveNameTooltips(el, () => data().primitives); + }} + innerHTML={data().readme} + /> + +
+

+
+ + ); +} diff --git a/site/src/routes/playground/$name.tsx~next b/site/src/routes/playground/$name.tsx~next new file mode 100644 index 000000000..b5fe242ce --- /dev/null +++ b/site/src/routes/playground/$name.tsx~next @@ -0,0 +1,67 @@ +import { type Component, Suspense, lazy } from "solid-js"; +import { Dynamic } from "solid-js/web"; +import { createFileRoute, notFound } from "@tanstack/solid-router"; +import { HEADER_HEIGHT } from "~/components/Header/Header.jsx"; +import NotFound from "~/components/NotFound.jsx"; +import { ClientOnly } from "~/primitives/client-only.js"; +import { kebabCaseToCapitalized } from "~/utils.js"; +import "./-playground.scss"; + +const modules = Object.entries( + import.meta.glob([ + "../../../../packages/*/dev/index.tsx", + // `filesystem` pulls in `chokidar`/`fsevents` which are Node-only and + // cannot be bundled for the browser. + "!../../../../packages/filesystem/**", + ]), +).reduce( + (acc, [path, fn]) => { + const name = path.split("/").at(-3); + if (name) acc[name] = lazy(fn as any); + return acc; + }, + {} as Record, +); + +export const Route = createFileRoute("/playground/$name")({ + component: PlaygroundPage, + // Render the 404 page for unknown package names (e.g. /playground/wefwe) + // instead of an empty playground. The loader checks the glob-generated + // `modules` map and throws `notFound()` when there's no match. + notFoundComponent: NotFound, + loader: ({ params }) => { + if (!(params.name in modules)) throw notFound(); + return null; + }, + head: ({ params, loaderData }) => ({ + meta: [ + { + title: + loaderData === null + ? `${kebabCaseToCapitalized(params.name)} — Playground` + : "Not Found — Solid Primitives", + }, + ], + }), +}); + +function PlaygroundPage() { + const params = Route.useParams(); + const formattedName = () => kebabCaseToCapitalized(params().name); + + return ( +
+
+

{formattedName()} — Playground

+
+ +
+ + + + + +
+
+ ); +} diff --git a/site/src/routes/playground/$name.tsx~port to tanstack start v1 b/site/src/routes/playground/$name.tsx~port to tanstack start v1 new file mode 100644 index 000000000..a68300e00 --- /dev/null +++ b/site/src/routes/playground/$name.tsx~port to tanstack start v1 @@ -0,0 +1,51 @@ +import { type Component, Suspense, lazy } from "solid-js"; +import { Dynamic } from "solid-js/web"; +import { createFileRoute } from "@tanstack/solid-router"; +import { HEADER_HEIGHT } from "~/components/Header/Header.jsx"; +import { ClientOnly } from "~/primitives/client-only.js"; +import { kebabCaseToCapitalized } from "~/utils.js"; +import "./-playground.scss"; + +const modules = Object.entries( + import.meta.glob([ + "../../../../packages/*/dev/index.tsx", + // `filesystem` pulls in `chokidar`/`fsevents` which are Node-only and + // cannot be bundled for the browser. + "!../../../../packages/filesystem/**", + ]), +).reduce( + (acc, [path, fn]) => { + const name = path.split("/").at(-3); + if (name) acc[name] = lazy(fn as any); + return acc; + }, + {} as Record, +); + +export const Route = createFileRoute("/playground/$name")({ + component: PlaygroundPage, + head: ({ params }) => ({ + meta: [{ title: `${kebabCaseToCapitalized(params.name)} — Playground` }], + }), +}); + +function PlaygroundPage() { + const params = Route.useParams(); + const formattedName = () => kebabCaseToCapitalized(params().name); + + return ( +
+
+

{formattedName()} — Playground

+
+ +
+ + + + + +
+
+ ); +}