From 0e80b4a526d370a66c1d34d29b62bb8df9420ef0 Mon Sep 17 00:00:00 2001 From: Arutiunian Artem Date: Tue, 8 Oct 2024 16:56:23 +0300 Subject: [PATCH] Devtools graph view (#932) * feat(devtools): add graph view * fix(devtools): graph view filters * fix(persist): prevent extra computed calls * fix(persist): do not accept an action * fix(persist): computed handling * fix(url): computed handling * docs: improve react-search example * feat(jsx): add FC type * feat(jsx): add MouseEventHandler type * fix(utils): prettify toStringKey output * fix(utils): toStringKey for Symbol.for * feat(devtools): graph view * fix(jsx): bool attribute * fix(jsx): types * fix(devtools): search matching * feat(utils): improve toStringKey * test(utils): fix toStringKey * fix(devtools): another huge refactoring * feat(devtools): exclude filters * feat(effects): explicit spawn action * fix(async): cache cause handling * test(async): infinity cache invalidation * docs(jsx): types * fix(devtools): filters enum * fix(devtools): filters highlight * feat(devtools): connectDevtools returns log function --- examples/model/search.ts | 22 +- examples/react-search/package.json | 1 + examples/react-search/src/app.tsx | 12 +- examples/react-search/src/main.tsx | 11 +- packages/async/package.json | 2 +- packages/async/src/withCache.test.ts | 44 ++- packages/async/src/withCache.ts | 154 +++------- packages/devtools/package.json | 5 +- packages/devtools/src/Graph.tsx | 290 ++++++++++++++++++ packages/devtools/src/Graph/reatomFilters.tsx | 274 +++++++++++++++++ .../devtools/src/Graph/reatomInspector.tsx | 195 ++++++++++++ packages/devtools/src/Graph/reatomLines.tsx | 139 +++++++++ packages/devtools/src/Graph/utils.ts | 52 ++++ packages/devtools/src/ObservableHQ.tsx | 126 ++++++++ packages/devtools/src/index.tsx | 162 ++++++---- packages/effects/src/index.ts | 26 +- packages/jsx/README.md | 5 + packages/jsx/src/index.ts | 10 +- packages/jsx/src/jsx.d.ts | 14 +- packages/persist/src/index.test.ts | 53 +++- packages/persist/src/index.ts | 35 ++- packages/url/src/index.ts | 107 +++---- packages/utils/src/index.test.ts | 48 ++- packages/utils/src/index.ts | 52 +++- 24 files changed, 1518 insertions(+), 321 deletions(-) create mode 100644 packages/devtools/src/Graph.tsx create mode 100644 packages/devtools/src/Graph/reatomFilters.tsx create mode 100644 packages/devtools/src/Graph/reatomInspector.tsx create mode 100644 packages/devtools/src/Graph/reatomLines.tsx create mode 100644 packages/devtools/src/Graph/utils.ts create mode 100644 packages/devtools/src/ObservableHQ.tsx diff --git a/examples/model/search.ts b/examples/model/search.ts index 632a3996b..0c2989fe0 100644 --- a/examples/model/search.ts +++ b/examples/model/search.ts @@ -8,12 +8,21 @@ import { withRetry, withAssign, action, + withComputed, + isInit, } from '@reatom/framework' import { withSearchParamsPersist } from '@reatom/url' +import { withLocalStorage } from '@reatom/persist-web-storage' -export const searchAtom = atom('', 'searchAtom') +export const searchAtom = atom('', 'searchAtom').pipe(withSearchParamsPersist('search')) export const pageAtom = atom(1, 'pageAtom').pipe( + withComputed((ctx, state) => { + // reset the state on other filters change + ctx.spy(searchAtom) + // check the init to do not drop the persist state + return isInit(ctx) ? state : 1 + }), withSearchParamsPersist('page', (page) => Number(page || 1)), withAssign((target, name) => ({ next: action((ctx) => target(ctx, (page) => page + 1), `${name}.next`), @@ -22,6 +31,7 @@ export const pageAtom = atom(1, 'pageAtom').pipe( ) export const issuesResource = reatomResource(async (ctx) => { + const { signal } = ctx.controller const query = ctx.spy(searchAtom) const page = ctx.spy(pageAtom) @@ -30,12 +40,12 @@ export const issuesResource = reatomResource(async (ctx) => { // debounce await ctx.schedule(() => sleep(350)) - const { items } = await api.searchIssues({ query, page, signal: ctx.controller.signal }) + const { items } = await api.searchIssues({ query, page, signal }) return items }, 'issues').pipe( withDataAtom([]), withErrorAtom(), - withCache({ length: Infinity, swr: false, staleTime: 3 * 60 * 1000 }), + withCache({ length: Infinity, swr: false, staleTime: 3 * 60 * 1000, withPersist: withLocalStorage }), withRetry({ onReject(ctx, error, retries) { if (error instanceof Error && error?.message.includes('rate limit')) { @@ -90,10 +100,8 @@ export const api = { perPage?: number signal: AbortSignal }): Promise { - const response = await fetch( - `https://api.github.com/search/issues?q=${query}&page=${page}&per_page=${perPage}`, - { signal }, - ) + const url = `https://api.github.com/search/issues?q=${query}&page=${page}&per_page=${perPage}` + const response = await fetch(url, { signal }) if (response.status !== 200) { const error = new Error(`HTTP Error: ${response.statusText}`) diff --git a/examples/react-search/package.json b/examples/react-search/package.json index 7602f1e1c..a1a98a576 100644 --- a/examples/react-search/package.json +++ b/examples/react-search/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "@reatom/devtools": "*", "@reatom/framework": "^3.*.*", "@reatom/npm-react": "^3.*.*", "react": "^18.2.0", diff --git a/examples/react-search/src/app.tsx b/examples/react-search/src/app.tsx index 0df314e69..446959652 100644 --- a/examples/react-search/src/app.tsx +++ b/examples/react-search/src/app.tsx @@ -1,10 +1,9 @@ import { reatomComponent } from '@reatom/npm-react' -import { searchAtom, issuesResource } from './model' +import { searchAtom, issuesResource, pageAtom } from './model' export const App = reatomComponent(({ ctx }) => { - const isLoading = Boolean( - ctx.spy(issuesResource.pendingAtom) || ctx.spy(issuesResource.retriesAtom), - ) + const isLoading = Boolean(ctx.spy(issuesResource.pendingAtom) || ctx.spy(issuesResource.retriesAtom)) + const page = ctx.spy(pageAtom) return (
@@ -13,6 +12,11 @@ export const App = reatomComponent(({ ctx }) => { onChange={(e) => searchAtom(ctx, e.currentTarget.value)} placeholder="Search" /> + + {page} + {isLoading && 'Loading...'}
    {ctx.spy(issuesResource.dataAtom).map(({ title }, i) => ( diff --git a/examples/react-search/src/main.tsx b/examples/react-search/src/main.tsx index df8af05c6..7c0644d7a 100644 --- a/examples/react-search/src/main.tsx +++ b/examples/react-search/src/main.tsx @@ -1,18 +1,17 @@ -import React from 'react' import ReactDOM from 'react-dom/client' import { createCtx, connectLogger } from '@reatom/framework' +import { connectDevtools } from '@reatom/devtools' import { reatomContext } from '@reatom/npm-react' import { App } from './app' const ctx = createCtx() connectLogger(ctx) +connectDevtools(ctx) const root = ReactDOM.createRoot(document.getElementById('root')!) root.render( - - - - - , + + + , ) diff --git a/packages/async/package.json b/packages/async/package.json index 6d815d14b..602eb5273 100644 --- a/packages/async/package.json +++ b/packages/async/package.json @@ -27,7 +27,7 @@ }, "dependencies": { "@reatom/core": "^3.5.0", - "@reatom/effects": "^3.7.0", + "@reatom/effects": "^3.10.0", "@reatom/hooks": "^3.2.0", "@reatom/primitives": "^3.5.0", "@reatom/utils": "^3.4.0" diff --git a/packages/async/src/withCache.test.ts b/packages/async/src/withCache.test.ts index 03be22624..0cdaacb88 100644 --- a/packages/async/src/withCache.test.ts +++ b/packages/async/src/withCache.test.ts @@ -11,9 +11,7 @@ import { reatomAsync, withAbort, withDataAtom, withCache, AsyncCtx } from './' const test = suite('withCache') test('withCache', async () => { - const fetchData = reatomAsync( - async (ctx, { a, b }: { a: number; b: number }) => a, - ).pipe(withDataAtom(0), withCache()) + const fetchData = reatomAsync(async (ctx, { a, b }: { a: number; b: number }) => a).pipe(withDataAtom(0), withCache()) const ctx = createTestCtx() await fetchData(ctx, { a: 400, b: 0 }) @@ -57,10 +55,7 @@ test('withCache dataAtom mapper', async () => { test('withCache swr true (default)', async () => { let i = 0 - const fetchData = reatomAsync((ctx) => Promise.resolve(++i)).pipe( - withDataAtom(0), - withCache(), - ) + const fetchData = reatomAsync((ctx) => Promise.resolve(++i)).pipe(withDataAtom(0), withCache()) const ctx = createTestCtx() const track = ctx.subscribeTrack(fetchData.dataAtom) @@ -139,11 +134,7 @@ test('withCache withAbort vary params', async () => { return n }) - const fetchData = reatomAsync(effect).pipe( - withDataAtom(0), - withCache(), - withAbort(), - ) + const fetchData = reatomAsync(effect).pipe(withDataAtom(0), withCache(), withAbort()) const ctx = createTestCtx() const track = ctx.subscribeTrack(fetchData.dataAtom) @@ -201,11 +192,7 @@ test('withCache withAbort same params', async () => { test('withCache and action mocking', async () => { const effect = mockFn(async (ctx: any, n: number) => n) - const fetchData = reatomAsync(effect).pipe( - withDataAtom(0), - withCache(), - withAbort(), - ) + const fetchData = reatomAsync(effect).pipe(withDataAtom(0), withCache(), withAbort()) const ctx = createTestCtx() ctx.mockAction(fetchData, async (ctx, n) => n * 10) @@ -257,10 +244,7 @@ test('do not cache aborted promise', async () => { ctx.controller.signal.throwIfAborted() return 1 }) - const fetchData = reatomAsync(effect).pipe( - withDataAtom(0), - withCache({ ignoreAbort: false }), - ) + const fetchData = reatomAsync(effect).pipe(withDataAtom(0), withCache({ ignoreAbort: false })) onConnect(fetchData.dataAtom, fetchData) const ctx = createTestCtx() @@ -281,10 +265,7 @@ test('do not cache aborted promise', async () => { test('should be able to manage cache manually', async () => { const effect = mockFn(async (ctx: any, n: number) => n) - const fetchData = reatomAsync(effect).pipe( - withDataAtom(0), - withCache({ swr: false }), - ) + const fetchData = reatomAsync(effect).pipe(withDataAtom(0), withCache({ swr: false })) const ctx = createTestCtx() fetchData(ctx, 1) @@ -308,4 +289,17 @@ test('should be able to manage cache manually', async () => { assert.is(effect.calls.length, 2) }) +test('Infinity cache invalidation', async () => { + const effect = mockFn(async (ctx: any, n: number) => n) + const fetchData = reatomAsync(effect).pipe(withDataAtom(0), withCache({ swr: false, staleTime: Infinity })) + const ctx = createTestCtx() + + await fetchData(ctx, 1) + await fetchData(ctx, 2) + assert.is(effect.calls.length, 2) + + await fetchData.cacheAtom.invalidate(ctx) + assert.is(effect.calls.length, 3) +}) + test.run() diff --git a/packages/async/src/withCache.ts b/packages/async/src/withCache.ts index 7debe0cd5..e4020494c 100644 --- a/packages/async/src/withCache.ts +++ b/packages/async/src/withCache.ts @@ -1,33 +1,19 @@ -import { - Action, - action, - ActionParams, - atom, - Atom, - AtomState, - Ctx, - Fn, -} from '@reatom/core' +import { Action, action, ActionParams, atom, Atom, AtomState, Ctx, Fn } from '@reatom/core' import { MapAtom, reatomMap, withAssign } from '@reatom/primitives' import { isDeepEqual, MAX_SAFE_TIMEOUT, setTimeout } from '@reatom/utils' import { type WithPersistOptions } from '@reatom/persist' - -import { - AsyncAction, - AsyncCtx, - AsyncDataAtom, - AsyncResp, - ControlledPromise, -} from '.' -import { handleEffect } from './handleEffect' import { onConnect } from '@reatom/hooks' import { __thenReatomed, abortCauseContext, getTopController, spawn, + // spawnAction } from '@reatom/effects' +import { handleEffect } from './handleEffect' +import { AsyncAction, AsyncCtx, AsyncDataAtom, AsyncResp, ControlledPromise } from '.' + export interface CacheRecord { clearTimeoutId: ReturnType /** It is more like **"lastRequest"**, @@ -43,8 +29,7 @@ export interface CacheRecord { version: number } -export interface CacheAtom - extends MapAtom> { +export interface CacheAtom extends MapAtom> { /** Clear all records and call the effect with the last params. */ invalidate: Action<[], null | ControlledPromise> setWithParams: Action<[params: Params, value: T]> @@ -52,9 +37,7 @@ export interface CacheAtom options: WithCacheOptions } -type CacheMapRecord = - | undefined - | CacheRecord, ActionParams> +type CacheMapRecord = undefined | CacheRecord, ActionParams> export type WithCacheOptions = { /** Define if the effect should be prevented from abort. @@ -114,12 +97,8 @@ export type WithCacheOptions = { /** Persist adapter, which will used with predefined optimal parameters */ withPersist?: ( - options: WithPersistOptions< - AtomState, ActionParams>> - >, - ) => ( - anAsync: CacheAtom, ActionParams>, - ) => CacheAtom, ActionParams> + options: WithPersistOptions, ActionParams>>>, + ) => (anAsync: CacheAtom, ActionParams>) => CacheAtom, ActionParams> } & ( | { /** Convert params to stable string and use as a map key. @@ -133,20 +112,12 @@ export type WithCacheOptions = { * Alternative to `paramsToKey`. * @default `isDeepEqual` from @reatom/utils */ - isEqual?: ( - ctx: Ctx, - prev: ActionParams, - next: ActionParams, - ) => boolean + isEqual?: (ctx: Ctx, prev: ActionParams, next: ActionParams) => boolean } ) type Find = Fn< - [ - ctx: Ctx, - params: ActionParams, - state?: AtomState, ActionParams>>, - ], + [ctx: Ctx, params: ActionParams, state?: AtomState, ActionParams>>], { cached?: CacheMapRecord; key: unknown } > @@ -194,8 +165,7 @@ export const withCache = // @ts-expect-error valid and correct JS shouldReject = false, } = swrOptions - if (staleTime !== Infinity) - staleTime = Math.min(MAX_SAFE_TIMEOUT, staleTime) + if (staleTime !== Infinity) staleTime = Math.min(MAX_SAFE_TIMEOUT, staleTime) const find: Find = paramsToKey ? (ctx, params, state = ctx.get(cacheAtom)) => { @@ -211,10 +181,7 @@ export const withCache = const findLatestWithValue = (ctx: Ctx, state = ctx.get(cacheAtom)) => { for (const cached of state.values()) { - if ( - cached.version > 0 && - (!latestCached || cached.lastUpdate > latestCached.lastUpdate) - ) { + if (cached.version > 0 && (!latestCached || cached.lastUpdate > latestCached.lastUpdate)) { var latestCached: undefined | ThisCacheRecord = cached } } @@ -239,9 +206,7 @@ export const withCache = staleTime === Infinity ? NOOP_TIMEOUT_ID : setTimeout(() => { - if ( - cacheAtom.get(ctx, key)?.clearTimeoutId === clearTimeoutId - ) { + if (cacheAtom.get(ctx, key)?.clearTimeoutId === clearTimeoutId) { cacheAtom.delete(ctx, key) } }, time) @@ -252,29 +217,24 @@ export const withCache = return clearTimeoutId } - const cacheAtom = (anAsync.cacheAtom = reatomMap( - new Map(), - `${anAsync.__reatom.name}._cacheAtom`, - ).pipe( + const cacheAtom = (anAsync.cacheAtom = reatomMap(new Map(), `${anAsync.__reatom.name}._cacheAtom`).pipe( withAssign((target, name) => ({ - setWithParams: action( - (ctx, params: ThisParams, value: AsyncResp) => { - const { cached, key } = find(ctx, params) - - cacheAtom.set(ctx, key, { - clearTimeoutId: planCleanup(ctx, key), - promise: undefined, - value, - version: cached ? cached.version + 1 : 1, - controller: new AbortController(), - lastUpdate: Date.now(), - params, - }) - - // TODO ? - // cached?.controller.abort() - }, - ), + setWithParams: action((ctx, params: ThisParams, value: AsyncResp) => { + const { cached, key } = find(ctx, params) + + cacheAtom.set(ctx, key, { + clearTimeoutId: planCleanup(ctx, key), + promise: undefined, + value, + version: cached ? cached.version + 1 : 1, + controller: new AbortController(), + lastUpdate: Date.now(), + params, + }) + + // TODO ? + // cached?.controller.abort() + }), deleteWithParams: action((ctx, params: ThisParams) => { const { cached, key } = find(ctx, params) if (cached) cacheAtom.delete(ctx, key) @@ -313,11 +273,7 @@ export const withCache = withPersist({ key: cacheAtom.__reatom.name!, // @ts-expect-error snapshot unknown type - fromSnapshot: ( - ctx, - snapshot: Array<[unknown, ThisCacheRecord]>, - state = new Map(), - ) => { + fromSnapshot: (ctx, snapshot: Array<[unknown, ThisCacheRecord]>, state = new Map()) => { if ( snapshot.length <= state?.size && snapshot.every(([, { params, value }]) => { @@ -335,11 +291,7 @@ export const withCache = if (restStaleTime <= 0) { newState.delete(key) } else { - rec.clearTimeoutId = planCleanup( - ctx, - key, - staleTime - (Date.now() - rec.lastUpdate), - ) + rec.clearTimeoutId = planCleanup(ctx, key, staleTime - (Date.now() - rec.lastUpdate)) } } @@ -357,27 +309,17 @@ export const withCache = return newState }, time: Math.min(staleTime, MAX_SAFE_TIMEOUT), - toSnapshot: (ctx, cache) => - [...cache].filter(([, rec]) => !rec.promise), + toSnapshot: (ctx, cache) => [...cache].filter(([, rec]) => !rec.promise), }), ) } - const swrPendingAtom = (anAsync.swrPendingAtom = atom( - 0, - `${anAsync.__reatom.name}.swrPendingAtom`, - )) - - const handlePromise = ( - ctx: Ctx, - key: unknown, - cached: ThisCacheRecord, - swr: boolean, - ) => { + const swrPendingAtom = (anAsync.swrPendingAtom = atom(0, `${anAsync.__reatom.name}.swrPendingAtom`)) + + const handlePromise = (ctx: Ctx, key: unknown, cached: ThisCacheRecord, swr: boolean) => { cached.clearTimeoutId = planCleanup(ctx, key) // the case: the whole cache was cleared and a new fetching was started - const isSame = () => - cacheAtom.get(ctx, key)?.clearTimeoutId === cached.clearTimeoutId + const isSame = () => cacheAtom.get(ctx, key)?.clearTimeoutId === cached.clearTimeoutId // @ts-expect-error could be reassigned by the testing package const { unstable_fn } = anAsync.__reatom @@ -387,7 +329,11 @@ export const withCache = return async (...a: Parameters) => { try { const value = await (ignoreAbort - ? spawn(a[0], unstable_fn, a.slice(1)) + ? spawn( + a[0], + (ctx, ...a) => unstable_fn({ ...ctx, controller: getTopController(ctx.cause) }, ...a), + a.slice(1), + ) : unstable_fn(...a)) res(value) @@ -431,10 +377,7 @@ export const withCache = const controller = getTopController(ctx.cause.cause!)! abortCauseContext.set(ctx.cause, (ctx.controller = controller)) - const paramsKey = params.slice( - 1, - 1 + (paramsLength ?? params.length), - ) as ThisParams + const paramsKey = params.slice(1, 1 + (paramsLength ?? params.length)) as ThisParams let { cached = { @@ -465,10 +408,7 @@ export const withCache = const cache = cacheAtom.set(ctx, key, cached) if (cache.size > length) deleteOldest(cache) - if ( - (cached.version === 0 && !cached.promise) || - (cached.promise && prevController.signal.aborted) - ) { + if ((cached.version === 0 && !cached.promise) || (cached.promise && prevController.signal.aborted)) { return handleEffect(anAsync, params, { effect: handlePromise(ctx, key, cached, false), }) @@ -514,9 +454,7 @@ export const withCache = // TODO handle it in dataAtom too to not couple to the order of operations if (withPersist && 'dataAtom' in anAsync) { - onConnect(anAsync.dataAtom!, (ctx) => - ctx.subscribe(cacheAtom, () => {}), - ) + onConnect(anAsync.dataAtom!, (ctx) => ctx.subscribe(cacheAtom, () => {})) } } diff --git a/packages/devtools/package.json b/packages/devtools/package.json index 0bcd4465f..eb5a2b37d 100644 --- a/packages/devtools/package.json +++ b/packages/devtools/package.json @@ -54,7 +54,8 @@ ], "devDependencies": { "@observablehq/inspector": "^5.0.0", - "@reatom/core": ">=3.8.1", - "@reatom/jsx": "^3.11.0" + "@reatom/framework": "latest", + "@reatom/jsx": "latest", + "zod": "^3.23.8" } } diff --git a/packages/devtools/src/Graph.tsx b/packages/devtools/src/Graph.tsx new file mode 100644 index 000000000..ea2ab3ec7 --- /dev/null +++ b/packages/devtools/src/Graph.tsx @@ -0,0 +1,290 @@ +import { + __root, + atom, + AtomCache, + Ctx, + toStringKey, + LinkedListAtom, + reatomLinkedList, + reatomResource, + withDataAtom, + Atom, + parseAtoms, + action, +} from '@reatom/framework' +import { h, hf, ctx } from '@reatom/jsx' +import { actionsStates, followingsMap, getColor, getId, history } from './Graph/utils' +import { reatomFilters } from './Graph/reatomFilters' +import { reatomInspector } from './Graph/reatomInspector' +import { reatomLines } from './Graph/reatomLines' +import { ObservableHQ } from './ObservableHQ' + +type Props = { + clientCtx: Ctx + getColor: typeof getColor + width: Atom + height: Atom +} + +export const Graph = ({ clientCtx, getColor, width, height }: Props) => { + const name = '_ReatomDevtools.Graph' + + const list = reatomLinkedList( + { + key: 'id', + create(ctx, patch: AtomCache) { + followingsMap.add(patch) + const { isAction, name } = patch.proto + let { state } = patch + if (isAction) { + state = actionsStates.get(patch) + if (state.length === 1) state = state[0] + } else { + history.add(patch) + } + const id = getId(patch) + const color = getColor(patch) + + let stringState: string + + const style = atom((ctx) => { + let display = 'list-item' + let background = 'none' + + for (const { search, type } of ctx.spy(filters.list.array)) { + const _type = ctx.spy(type) + + if (_type === 'off') continue + + try { + const result = new RegExp(`.*${ctx.spy(search)}.*`, 'i').test(name!) + + if (_type === 'match' && !result) { + display = 'none' + } + if ((_type === 'mismatch' || _type === 'exclude') && result) { + display = 'none' + } + + if (_type === 'highlight' && result) { + background = 'rgb(255 255 255 / 50%)' + } + } catch {} + } + + const search = ctx.spy(valuesSearch) + if (search) { + stringState ??= toStringKey(patch.state) + .replace(/\[reatom .*?\]/g, `\n`) + .toLowerCase() + + if (!stringState.includes(search)) { + display = 'none' + } + } + + return { + display, + background, + } + }, `${name}._style`) + + const handleClick = (ctx: Ctx) => { + lines.highlight(ctx, { svg, patch }) + } + + return ( +
  • { + inspector.hide(ctx, e.relatedTarget) + }} + style={style} + css={` + padding: 5px; + font-size: 16px; + &::marker { + content: ''; + } + `} + > + + + {name} + {atom((ctx) => (ctx.spy(filters.inlinePreview) ? : ))} +
  • + ) + }, + }, + `${name}.list`, + ) + + const lines = reatomLines(`${name}.lines`) + list.clear.onCall(lines.clear) + + const redrawLines = action((ctx) => lines.redraw(ctx, svg), `${name}.redrawLines`) + + const filters = reatomFilters( + { list: list as unknown as LinkedListAtom, clearLines: lines.clear, redrawLines }, + `${name}.filters`, + ) + const valuesSearch = atom((ctx) => { + const search = ctx.spy(filters.valuesSearch) + + return search.length < 2 ? '' : search.toLocaleLowerCase() + }) + + const inspector = reatomInspector({ filters }, `${name}.inspector`) + + const listHeight = reatomResource(async (ctx) => { + ctx.spy(list) + ctx.spy(width) + ctx.spy(height) + parseAtoms(ctx, filters) + await ctx.schedule(() => new Promise((r) => requestAnimationFrame(r))) + return `${listEl.getBoundingClientRect().height}px` + }, `${name}.listHeight`).pipe(withDataAtom('0px')).dataAtom + + const subscribe = () => + clientCtx.subscribe(async (logs) => { + const insertStates = new Map() + for (let i = 0; i < logs.length; i++) { + const patch = logs[i]! + insertStates.set(patch, 0) + if (patch.proto.isAction) actionsStates.set(patch, patch.state.slice(0)) + } + + await null + + const exludes = ctx + .get(filters.list.array) + .filter(({ type }) => ctx.get(type) === 'exclude') + .map(({ search }) => ctx.get(search)) + const isPass = (patch: AtomCache) => + exludes.every((search) => !new RegExp(`.*${search}.*`, 'i').test(patch.proto.name!)) + + // fix the case when "cause" appears in the logs after it patch + const insert = (patch: AtomCache) => { + const cause = patch.cause! + if (insertStates.get(cause) === 0) { + if (cause.cause) insert(cause.cause) + if (isPass(cause)) list.create(ctx, cause) + insertStates.set(cause, 1) + } + if (insertStates.get(patch) === 0) { + if (isPass(patch)) list.create(ctx, patch) + insertStates.set(patch, 1) + } + } + list.batch(ctx, () => { + for (const patch of logs) { + insert(patch) + } + }) + }) + + const svg = ( + (ctx.spy(lines).size ? 'auto' : 'none'))} + css={` + position: absolute; + width: calc(100% - 70px); + height: var(--height); + top: 0; + left: 70px; + pointer-events: var(--pe); + `} + > + {lines} + + ) as SVGElement + + const listEl = ( +
      + {list} +
    + ) + + const container = ( +
    + {filters.element} + {inspector.element} +
    + {svg} + {listEl} +
    +
    + ) + + return container +} diff --git a/packages/devtools/src/Graph/reatomFilters.tsx b/packages/devtools/src/Graph/reatomFilters.tsx new file mode 100644 index 000000000..7d7f512c0 --- /dev/null +++ b/packages/devtools/src/Graph/reatomFilters.tsx @@ -0,0 +1,274 @@ +import { parseAtoms, assign, LinkedListAtom, reatomString, Action, atom } from '@reatom/framework' +import { h, hf, JSX } from '@reatom/jsx' +import { reatomZod } from '@reatom/npm-zod' +import { z } from 'zod' + +const Filters = z.object({ + hoverPreview: z.boolean(), + inlinePreview: z.boolean(), + valuesSearch: z.string(), + list: z.array( + z.object({ + name: z.string().readonly(), + search: z.string(), + type: z.enum(['match', 'mismatch', 'exclude', 'highlight', 'off']), + readonly: z.boolean().readonly(), + }), + ), +}) +type Filters = z.infer + +const initState: Filters = { + hoverPreview: true, + inlinePreview: false, + valuesSearch: '', + list: [{ name: 'private', search: `(^_)|(\._)`, type: 'mismatch', readonly: true }], +} +const initSnapshot = JSON.stringify(initState) +const version = 'v13' + +const FilterButton = (props: JSX.IntrinsicElements['button']) => ( + + + + {filters.list.reatomMap((ctx, filter) => { + const id = `${filters.list.__reatom.name}-${filter.name}` + return ( + + + + + + ) + })} +
    + {filter.name} + + ctx.spy(filter.type) === 'match')} + on:click={filter.type.setMatch} + > + = + + ctx.spy(filter.type) === 'mismatch')} + on:click={filter.type.setMismatch} + > + ≠ + + ctx.spy(filter.type) === 'highlight')} + style={{ 'font-size': '10px' }} + on:click={filter.type.setHighlight} + > + 💡 + + ctx.spy(filter.type) === 'exclude')} + style={{ 'font-size': '10px' }} + on:click={filter.type.setExclude} + > + 🗑️ + + ctx.spy(filter.type) === 'off')} + on:click={filter.type.setOff} + > + ⊘ + + + + +
    + + +
    + actions +
    + + + + +
    +
    + + ), + }) +} diff --git a/packages/devtools/src/Graph/reatomInspector.tsx b/packages/devtools/src/Graph/reatomInspector.tsx new file mode 100644 index 000000000..6d90401bd --- /dev/null +++ b/packages/devtools/src/Graph/reatomInspector.tsx @@ -0,0 +1,195 @@ +import { __root, action, atom, AtomCache, parseAtoms, withReset } from '@reatom/framework' +import { h, hf, JSX } from '@reatom/jsx' +import { ObservableHQ, ObservableHQActionButton } from '../ObservableHQ' +import { reatomFilters } from './reatomFilters' +import { actionsStates, history } from './utils' +import { withComputed } from '@reatom/primitives' + +type InspectorState = + | { kind: 'hidden' } + | { kind: 'open'; patch: AtomCache } + | { kind: 'fixed'; patch: AtomCache; element: HTMLElement } + +export const reatomInspector = ({ filters }: { filters: ReturnType }, name: string) => { + const state = atom({ kind: 'hidden' }, `${name}.state`) + const patch = atom((ctx) => { + const s = ctx.spy(state) + + return s.kind === 'hidden' ? null : s.patch + }, `${name}.patch`) + const patchState = atom(null, `${name}.patchState`).pipe( + withComputed((ctx) => { + const patchState = ctx.spy(patch) + + if (patchState?.proto.isAction) { + const calls = actionsStates.get(patchState) + return calls?.length === 1 ? calls[calls.length - 1] : calls + } + + return patchState?.state instanceof URL ? patchState?.state.href : patchState?.state + }), + ) + const patchHistory = atom((ctx) => { + const patchState = ctx.spy(patch) + + if (patchState?.proto.isAction) return [patchState] + + const patchHistory = patchState && history.get(patchState.proto) + + if (!patchHistory) return null + + const idx = patchHistory.indexOf(patchState) + + if (idx === -1) return [] + + return patchHistory.slice(idx) + }, `${name}.patchHistory`).pipe(withReset()) + patchHistory.reset.onCall((ctx) => { + const patchState = ctx.get(patch) + + if (patchState) history.delete(patchState.proto) + }) + + const open = action((ctx, patch: AtomCache) => { + if (ctx.get(state).kind !== 'fixed') { + state(ctx, { kind: 'open', patch }) + } + }, `${name}.open`) + + const fix = action((ctx, patch: AtomCache, patchElement: HTMLElement) => { + const s = ctx.get(state) + + const toFix = () => { + state(ctx, { kind: 'fixed', patch, element: patchElement }) + patchElement.style.fontWeight = 'bold' + element.focus() + } + + if (s.kind === 'fixed') { + s.element.style.fontWeight = 'normal' + if (s.patch === patch) { + state(ctx, { kind: 'hidden' }) + } else { + toFix() + } + } else { + toFix() + } + }, `${name}.fixed`) + + const hide = action((ctx, relatedElement: EventTarget | null) => { + if (!(relatedElement instanceof Node && element.contains(relatedElement))) { + if (ctx.get(state).kind !== 'fixed') { + state(ctx, { kind: 'hidden' }) + } + } + }, `${name}.hide`) + + const close: JSX.EventHandler = action((ctx) => { + state(ctx, (s) => { + if (s.kind === 'fixed') { + s.element.style.fontWeight = 'normal' + } + return { kind: 'hidden' } + }) + }, `${name}.close`) + + const filtersHeight = atom((ctx) => filters.element.clientHeight + 'px', `${name}.filtersHeight`).pipe( + withComputed((ctx, s) => { + ctx.spy(state) + parseAtoms(ctx, filters) + return s + }), + ) + + const OPACITY = { + hidden: '0', + open: '0.8', + fixed: '1', + } + + const element = ( + ctx.spy(state).kind !== 'hidden')} + css:filtersHeight={filtersHeight} + css:pe={atom((ctx) => (ctx.spy(state).kind === 'hidden' ? 'none' : 'all'))} + css:opacity={atom((ctx) => OPACITY[ctx.spy(state).kind])} + css={` + position: absolute; + left: 139px; + top: calc(var(--filtersHeight) + 20px); + width: calc(100% - 160px); + height: calc(100% - var(--filtersHeight) - 40px); + max-height: 100%; + overflow: auto; + background: var(--devtools-bg); + padding: 0; + margin: 0; + border: none; + border-radius: 2px; + box-shadow: + 0 0 0 1px rgba(0, 0, 0, 0.1), + 0 4px 11px rgba(0, 0, 0, 0.1); + z-index: 1; + /* pointer-events: var(--pe); */ + opacity: var(--opacity); + transition: opacity 0.2s; + &:hover { + opacity: 1; + } + `} + > +
    +

    + {atom((ctx) => ctx.spy(patch)?.proto.name)} +

    + + x + + } + /> +
    +
    +
    +
    +

    + history +

    +
    + + 🗑️ + + } + /> +
    +
    + ) + + return Object.assign(state, { element, open, fix, hide }) +} diff --git a/packages/devtools/src/Graph/reatomLines.tsx b/packages/devtools/src/Graph/reatomLines.tsx new file mode 100644 index 000000000..46c6fc067 --- /dev/null +++ b/packages/devtools/src/Graph/reatomLines.tsx @@ -0,0 +1,139 @@ +import { + action, + Action, + atom, + AtomCache, + Ctx, + type Rec, + type LinkedListAtom, + AtomMut, + sleep, + batch, +} from '@reatom/framework' +import { h, hf, JSX } from '@reatom/jsx' +import { followingsMap, getId, getStartCause, highlighted } from './utils' +import { reatomLinkedList } from '@reatom/primitives' + +interface Params { + patch: AtomCache + svg: SVGElement +} + +export interface Lines extends LinkedListAtom<[Params], JSX.Element, never> { + highlight: Action<[Params], void> + redraw: Action<[svg: SVGElement]> +} + +export const reatomLines = (name: string): Lines => { + const highlightedTargets = atom>([], `${name}:highlightedTargets`) + + const lines = reatomLinkedList( + { + // key: 'id', + create(ctx, { svg, patch }: Params) { + const containerRec = svg.getBoundingClientRect() + let points = '' + + const calc = (to: AtomCache, from: AtomCache): null | AtomCache => { + if (highlighted.has(to)) return null + + const toRec = document.getElementById(getId(to))?.getBoundingClientRect() + const fromEl = document.getElementById(getId(from)) + const fromRec = fromEl?.getBoundingClientRect() + + if (!toRec || !fromRec) { + return from + } + + // @ts-expect-error + if (fromEl?.computedStyleMap().get('display')?.value === 'none') { + return from.cause && calc(to, from.cause) + } + + const toX = 70 + toRec.x + -containerRec.x + const toY = toRec.y + 27 - containerRec.y + + const fromX = 70 + fromRec.x + -containerRec.x + const fromY = fromRec.y + 27 - containerRec.y + + const middleX = toX + (toY - fromY) / 10 + const middleY = fromY + (toY - fromY) / 2 + + points += `${toX},${toY} ${middleX},${middleY} ${fromX},${fromY} ` + + highlighted.add(to) + + return from + } + + let target: null | AtomCache = patch + while (target && target.cause) { + target = calc(target, target.cause!) + + if (!target?.cause?.cause) break + } + + return ( + + ) + }, + }, + name, + ) + lines.clear.onCall((ctx) => { + highlightedTargets(ctx, []) + highlighted.clear() + }) + + const highlight = action((ctx, { svg, patch }: Params) => { + let touched = new Set() + const calcFollowing = (target: AtomCache) => { + const followings = followingsMap.get(target) + + if (!touched.has(target) && followings?.length) { + touched.add(target) + for (const following of followings) { + calcFollowing(following) + } + } else if (!highlighted.has(target)) { + // TODO + // let qqq = target + // while (qqq && qqq.cause) { + // calcFollowing((qqq = qqq.cause)) + // } + + lines.create(ctx, { svg, patch: target }) + } + } + + lines.batch(ctx, () => { + calcFollowing(patch) + }) + highlightedTargets(ctx, (state) => [...state, patch]) + }) + + const redraw = action(async (ctx, svg: SVGElement) => { + const targets = ctx.get(highlightedTargets) + lines.clear(ctx) + await ctx.schedule(() => sleep(0)) + batch(ctx, () => { + for (const target of targets) { + highlight(ctx, { svg, patch: target }) + } + }) + }, `${name}:redraw`) + + return Object.assign(lines, { highlight, redraw }) +} diff --git a/packages/devtools/src/Graph/utils.ts b/packages/devtools/src/Graph/utils.ts new file mode 100644 index 000000000..09a0b4b5f --- /dev/null +++ b/packages/devtools/src/Graph/utils.ts @@ -0,0 +1,52 @@ +import { __root, AtomCache, AtomProto } from '@reatom/framework' + +export const getColor = ({ proto }: AtomCache): string => + proto.isAction + ? proto.name!.endsWith('.onFulfill') + ? '#e6ab73' + : proto.name!.endsWith('.onReject') + ? '#e67373' + : '#ffff80' + : '#151134' + +export const getStartCause = (cause: AtomCache): AtomCache => + cause.cause?.cause == null ? cause : getStartCause(cause.cause) + +const idxMap = new WeakMap() +let idx = 0 +export const getId = (node: AtomCache) => { + let id = idxMap.get(node) + if (!id) { + idxMap.set(node, (id = `${node.proto.name}-${++idx}`)) + } + return id +} + +export const followingsMap = new (class extends WeakMap> { + add(patch: AtomCache) { + if (patch.cause?.cause) { + let followings = this.get(patch.cause) + if (!followings) { + this.set(patch.cause, (followings = [])) + } + followings.push(patch) + } + } +})() + +export const highlighted = new Set() + +export const actionsStates = new WeakMap>() + +export const history = new (class extends WeakMap> { + add(patch: AtomCache) { + let list = this.get(patch.proto) + if (!list) { + list = [] + this.set(patch.proto, list) + } else { + if (list.length > 6) list.pop() + } + list.unshift(patch) + } +})() diff --git a/packages/devtools/src/ObservableHQ.tsx b/packages/devtools/src/ObservableHQ.tsx new file mode 100644 index 000000000..4fa1d70d9 --- /dev/null +++ b/packages/devtools/src/ObservableHQ.tsx @@ -0,0 +1,126 @@ +import { Atom, isAction, isAtom, type Rec } from '@reatom/core' +import { FC, h, mount, JSX } from '@reatom/jsx' +// @ts-expect-error TODO write types +import { Inspector } from '@observablehq/inspector' +// @ts-expect-error +import observablehqStyles from '../../../node_modules/@observablehq/inspector/dist/inspector.css' +import { noop, parseAtoms } from '@reatom/framework' + +export const ObservableHQActionButton = (props: JSX.ButtonHTMLAttributes) => { + return ( + ) - const inspector = new Inspector(inspectorEl) as { fulfilled(data: any): void } const reloadEl = ( ) - const observableContainer = ( -
    - ) - const containerEl = (
    {logo} - {reloadEl} - {logEl} - {observableContainer} + {viewSwitchEl} +
    (ctx.spy(viewSwitch) ? 'none' : 'block'))} + > + {reloadEl} + {logEl} + +
    +
    (ctx.spy(viewSwitch) ? 'block' : 'none'))} + > + +
    ) - observableContainer.attachShadow({ mode: 'open' }).append( - , - inspectorEl, - ) - - const logObject: Rec = {} const touched = new WeakSet() - ctx.subscribe(async (logs) => { + clientCtx.subscribe(async (logs) => { // await null // needed to prevent `Maximum call stack size exceeded` coz `parseAtoms` for (const { proto, state } of logs) { @@ -230,7 +247,7 @@ export const connectDevtools = async ( continue } - let thisLogObject = logObject + let thisLogObject = ctx.get(snapshot) path.forEach((key, i, { length }) => { if (i === length - 1) { @@ -260,11 +277,20 @@ export const connectDevtools = async ( }) const clearId = setInterval(() => { - if (Object.keys(logObject).length > 0) { - inspector.fulfilled(logObject) + if (Object.keys(ctx.get(snapshot)).length > 0) { + snapshot.forceUpdate(ctx) clearTimeout(clearId) } }, 100) mount(document.body, containerEl) } + +export const connectDevtools = (...[ctx, options]: Parameters) => { + _connectDevtools(ctx, options) + + return (name: string, payload: T): T => { + const logAction = action((ctx, payload: T) => payload, name) + return logAction(ctx, payload) + } +} diff --git a/packages/effects/src/index.ts b/packages/effects/src/index.ts index 6ebf2fea4..18a9a552f 100644 --- a/packages/effects/src/index.ts +++ b/packages/effects/src/index.ts @@ -10,6 +10,7 @@ import { Ctx, CtxSpy, Fn, + isAtom, throwReatomError, Unsubscribe, } from '@reatom/core' @@ -270,8 +271,8 @@ export const concurrent: { } = (fn: Fn, strategy: 'last-in-win' | 'first-in-win' = 'last-in-win'): Fn => { const abortControllerAtom = atom(null, `${__count('_concurrent')}.abortControllerAtom`) - const result = Object.assign( - (ctx: Ctx, ...a: any[]) => { + const target = action( + (ctx: Ctx, topCtx: Ctx, ...a: any[]) => { const prevController = ctx.get(abortControllerAtom) const controller = new AbortController() // do it outside of the schedule to save the call stack @@ -294,15 +295,15 @@ export const concurrent: { } } - const unabort = onCtxAbort(ctx, (error) => { + const unabort = onCtxAbort(topCtx, (error) => { // prevent unhandled error for abort if (res instanceof Promise) res.catch(noop) controller.abort(error) }) - ctx = { ...ctx, cause: { ...ctx.cause } } + abortCauseContext.set(ctx.cause, controller) - var res = fn(withAbortableSchedule(ctx) as CtxSpy, ...a) + var res = fn(withAbortableSchedule({ ...ctx, spy: topCtx.spy }) as CtxSpy, ...a) if (res instanceof Promise) { res = res.finally(() => { if (strategy === 'first-in-win') { @@ -323,6 +324,12 @@ export const concurrent: { } return res }, + isAtom(fn) ? `${fn.__reatom.name}._concurrent` : '_concurrent', + ) + + const result = Object.assign( + (ctx: Ctx, ...a: any[]) => target(ctx, ctx, ...a), + target, // if the `fn` is an atom we need to assign all related properties fn, ) @@ -340,15 +347,18 @@ export const withConcurrency: { (target: Action): Action => concurrent(target, strategy as any) +export const _spawn = action((ctx, fn: Fn, controller: AbortController, ...args: any[]) => { + abortCauseContext.set(ctx.cause, controller) + return fn(ctx, ...args) +}, '_spawn') + export const spawn = ( ctx: Ctx, fn: Fn<[Ctx, ...Args], Payload>, args: Args = [] as any[] as Args, controller = new AbortController(), ): Payload => { - ctx = { ...ctx, cause: { ...ctx.cause } } - abortCauseContext.set(ctx.cause, controller) - return fn(ctx, ...args) + return _spawn(ctx, fn, controller, ...args) } export interface ReactionAtom extends Atom { diff --git a/packages/jsx/README.md b/packages/jsx/README.md index a4e665f5f..9cce6c014 100644 --- a/packages/jsx/README.md +++ b/packages/jsx/README.md @@ -324,9 +324,14 @@ To type your custom component props accepting general HTML attributes, for examp ```tsx import { type JSX } from '@reatom/jsx' +// allow only plain data types export interface InputProps extends JSX.InputHTMLAttributes { defaultValue?: string } +// allow plain data types and atoms +export type InputProps = JSX.IntrinsicElements['input'] & { + defaultValue?: string +} export const Input = ({ defaultValue, ...props }: InputProps) => { props.value ??= defaultValue diff --git a/packages/jsx/src/index.ts b/packages/jsx/src/index.ts index 4206cf10a..c13aa2c07 100644 --- a/packages/jsx/src/index.ts +++ b/packages/jsx/src/index.ts @@ -2,7 +2,11 @@ import { action, Atom, AtomMut, createCtx, Ctx, Fn, isAtom, Rec, throwReatomErro import { isObject, random } from '@reatom/utils' import { type LinkedList, type LLNode, isLinkedListAtom, LL_NEXT } from '@reatom/primitives' import type { JSX } from './jsx' + declare type JSXElement = JSX.Element + +export type FC = (props: Props & { children?: JSXElement }) => JSXElement + export type { JSXElement, JSX } type DomApis = Pick< @@ -114,12 +118,14 @@ export const reatomJsx = (ctx: Ctx, DOM: DomApis = globalThis.window) => { else element.style.setProperty(key, val[key]) } } else if (key.startsWith('prop:')) { - ;(element as any)[key.slice(5)] = val + // @ts-expect-error + element[key.slice(5)] = val } else { if (key.startsWith('attr:')) { key = key.slice(5) } - if (val == null) element.removeAttribute(key) + if (val == null || val === false) element.removeAttribute(key) + else if (val === true) element.setAttribute(key, '') else element.setAttribute(key, String(val)) } } diff --git a/packages/jsx/src/jsx.d.ts b/packages/jsx/src/jsx.d.ts index 44763de6e..da8f147b2 100644 --- a/packages/jsx/src/jsx.d.ts +++ b/packages/jsx/src/jsx.d.ts @@ -51,7 +51,17 @@ export namespace JSX { [css: `css:${string}`]: string | number | false | null | undefined } - interface EventHandler { + interface EventHandler { + ( + ctx: Ctx, + e: E & { + currentTarget: T + target: Element + }, + ): void + } + + interface MouseEventHandler { ( ctx: Ctx, e: E & { @@ -2072,7 +2082,7 @@ export namespace JSX { section: HTMLAttributes select: SelectHTMLAttributes slot: HTMLSlotElementAttributes -HTMLElementTagNameMap: HTMLAttributes + HTMLElementTagNameMap: HTMLAttributes source: SourceHTMLAttributes span: HTMLAttributes strong: HTMLAttributes diff --git a/packages/persist/src/index.test.ts b/packages/persist/src/index.test.ts index 487b92b80..5b12291a7 100644 --- a/packages/persist/src/index.test.ts +++ b/packages/persist/src/index.test.ts @@ -1,8 +1,10 @@ import { test } from 'uvu' import * as assert from 'uvu/assert' -import { atom } from '@reatom/core' +import { action, atom } from '@reatom/core' import { createTestCtx } from '@reatom/testing' -import { noop } from '@reatom/utils' +import { noop, random } from '@reatom/utils' +import { withComputed } from '@reatom/primitives' +import { reatomResource } from '@reatom/async' import { createMemStorage, reatomPersist } from './' @@ -102,4 +104,51 @@ test('should not skip double update', async () => { ;('👍') //? }) +test('should memoize a computer', () => { + const ctx = createTestCtx() + const storage = withSomePersist.storageAtom( + ctx, + createMemStorage({ + name: 'test', + snapshot: { + a: 1, + }, + }), + ) + + const noop = atom({}) + const a = atom(0).pipe( + withComputed((ctx, state) => { + ctx.spy(noop) + computedCalls++ + return state + }), + withSomePersist('a'), + ) + let computedCalls = 0 + + assert.is(ctx.get(a), 1) + assert.is(computedCalls, 1) + + // const rec = storage.get(ctx, 'a')! + storage.set(ctx, 'a', { + data: 2, + fromState: false, + id: random(), + timestamp: Date.now(), + to: Date.now() + 5 * 1000, + version: 0, + }) + assert.is(ctx.get(a), 2) + assert.is(computedCalls, 1) + + noop(ctx, {}) + ctx.get(a) + assert.is(computedCalls, 2) +}) + +test('should not accept an action', () => { + assert.throws(() => reatomResource(async () => {}).pipe(withSomePersist('test'))) +}) + test.run() diff --git a/packages/persist/src/index.ts b/packages/persist/src/index.ts index fb9bbfe8a..363daac89 100644 --- a/packages/persist/src/index.ts +++ b/packages/persist/src/index.ts @@ -1,5 +1,6 @@ import { __root, + Action, atom, Atom, AtomCache, @@ -99,7 +100,9 @@ export const reatomPersist = ( version = 0, }: WithPersistOptions> = typeof options === 'string' ? { key: options } : options const proto = anAtom.__reatom - const { initState } = proto + const { initState, isAction } = proto + + throwReatomError(isAction, 'cannot apply persist to an action') const getPersistRecord = (ctx: Ctx, state: PersistRecord | null = null) => { const rec = ctx.get(storageAtom).get(ctx, key) @@ -135,7 +138,35 @@ export const reatomPersist = ( state = fromPersistRecord(ctx, rec, state) }) - return computer ? computer(ctx, state) : state + if (computer) { + const { pubs } = ctx.cause + + const isInit = pubs.length === 0 + const hasOtherDeps = pubs.length > 1 + + if ( + isInit || + (hasOtherDeps && + pubs.some( + (pub, i) => + i !== 0 && + !Object.is( + pub.state, + // @ts-expect-error + ctx.get({ __reatom: pub.proto }), + ), + )) + ) { + state = computer(ctx, state) as typeof state + } else { + for (let index = 1; index < pubs.length; index++) { + // @ts-expect-error + ctx.spy({ __reatom: pubs[index]!.proto }) + } + } + } + + return state } onConnect( diff --git a/packages/url/src/index.ts b/packages/url/src/index.ts index 28630042e..15015a927 100644 --- a/packages/url/src/index.ts +++ b/packages/url/src/index.ts @@ -1,14 +1,4 @@ -import { - Action, - Atom, - AtomMut, - AtomState, - Ctx, - Fn, - Rec, - action, - atom, -} from '@reatom/core' +import { Action, Atom, AtomMut, AtomState, Ctx, Fn, Rec, action, atom } from '@reatom/core' import { abortCauseContext } from '@reatom/effects' import { getRootCause, withInit } from '@reatom/hooks' import { noop } from '@reatom/utils' @@ -32,11 +22,7 @@ export interface SearchParamsAtom extends Atom> { set: Action<[key: string, value: string, replace?: boolean], void> del: Action<[key: string, replace?: boolean], void> /** create AtomMut which will synced with the specified query parameter */ - lens( - key: string, - parse?: (value?: string) => T, - serialize?: (value: T) => undefined | string, - ): AtomMut + lens(key: string, parse?: (value?: string) => T, serialize?: (value: T) => undefined | string): AtomMut /** create AtomMut which will synced with the specified query parameter */ lens( key: string, @@ -59,9 +45,7 @@ const browserSync = (url: URL, replace?: boolean) => { else history.pushState({}, '', url.href) } /**Browser settings allow handling of the "popstate" event and a link click. */ -const createBrowserUrlAtomSettings = ( - shouldCatchLinkClick = true, -): AtomUrlSettings => ({ +const createBrowserUrlAtomSettings = (shouldCatchLinkClick = true): AtomUrlSettings => ({ init: (ctx: Ctx) => { // do not store causes for IO events ctx = { ...ctx, cause: getRootCause(ctx.cause) } @@ -108,9 +92,7 @@ const createBrowserUrlAtomSettings = ( } }) - globalThis.addEventListener('popstate', (event) => - updateFromSource(ctx, new URL(location.href)), - ) + globalThis.addEventListener('popstate', (event) => updateFromSource(ctx, new URL(location.href))) if (shouldCatchLinkClick) document.body.addEventListener('click', click) return new URL(location.href) @@ -120,28 +102,18 @@ const createBrowserUrlAtomSettings = ( }, }) -const settingsAtom = atom( - createBrowserUrlAtomSettings(), - 'urlAtom.settingAtom', -) +const settingsAtom = atom(createBrowserUrlAtomSettings(), 'urlAtom.settingAtom') export const setupUrlAtomSettings = action( - ( - ctx, - init: (ctx: Ctx) => URL, - sync: (ctx: Ctx, url: URL, replace?: boolean) => void = noop, - ) => { + (ctx, init: (ctx: Ctx) => URL, sync: (ctx: Ctx, url: URL, replace?: boolean) => void = noop) => { settingsAtom(ctx, { init, sync }) }, 'urlAtom.setupUrlAtomSettings', ) -export const setupUrlAtomBrowserSettings = action( - (ctx, shouldCatchLinkClick: boolean) => { - settingsAtom(ctx, createBrowserUrlAtomSettings(shouldCatchLinkClick)) - }, - 'urlAtom.setupUrlAtomBrowserSettings', -) +export const setupUrlAtomBrowserSettings = action((ctx, shouldCatchLinkClick: boolean) => { + settingsAtom(ctx, createBrowserUrlAtomSettings(shouldCatchLinkClick)) +}, 'urlAtom.setupUrlAtomBrowserSettings') const _urlAtom = atom(null as any as URL, 'urlAtom') export const urlAtom: UrlAtom = Object.assign( @@ -159,24 +131,13 @@ export const urlAtom: UrlAtom = Object.assign( _urlAtom, { settingsAtom, - go: action( - (ctx, path, replace?: boolean) => - urlAtom(ctx, (url) => new URL(path, url), replace), - 'urlAtom.go', - ), - match: (path: string) => - atom( - (ctx) => ctx.spy(urlAtom).pathname.startsWith(path), - `urlAtom.match#${path}`, - ), + go: action((ctx, path, replace?: boolean) => urlAtom(ctx, (url) => new URL(path, url), replace), 'urlAtom.go'), + match: (path: string) => atom((ctx) => ctx.spy(urlAtom).pathname.startsWith(path), `urlAtom.match#${path}`), }, ).pipe(withInit((ctx) => ctx.get(settingsAtom).init(ctx))) export const searchParamsAtom: SearchParamsAtom = Object.assign( - atom( - (ctx) => Object.fromEntries(ctx.spy(urlAtom).searchParams), - 'searchParamsAtom', - ), + atom((ctx) => Object.fromEntries(ctx.spy(urlAtom).searchParams), 'searchParamsAtom'), { set: action((ctx, key, value, replace) => { const url = ctx.get(urlAtom) @@ -191,10 +152,7 @@ export const searchParamsAtom: SearchParamsAtom = Object.assign( urlAtom(ctx, newUrl, replace) }, 'searchParamsAtom._del') satisfies SearchParamsAtom['del'], lens: ((key, ...a: Parameters) => - atom( - getSearchParamsOptions(...a).parse(), - `searchParamsAtom#${a[0]}`, - ).pipe( + atom(getSearchParamsOptions(...a).parse(), `searchParamsAtom#${a[0]}`).pipe( // TODO // @ts-expect-error withSearchParamsPersist(key, ...a), @@ -204,10 +162,7 @@ export const searchParamsAtom: SearchParamsAtom = Object.assign( const getSearchParamsOptions = ( ...a: - | [ - parse?: (value?: string) => unknown, - serialize?: (value: unknown) => undefined | string, - ] + | [parse?: (value?: string) => unknown, serialize?: (value: unknown) => undefined | string] | [ options: { parse?: (value?: string) => unknown @@ -251,10 +206,7 @@ export function withSearchParamsPersist( export function withSearchParamsPersist( key: string, ...a: - | [ - parse?: (value?: string) => unknown, - serialize?: (value: unknown) => undefined | string, - ] + | [parse?: (value?: string) => unknown, serialize?: (value: unknown) => undefined | string] | [ options: { parse?: (value?: string) => unknown @@ -285,7 +237,34 @@ export function withSearchParamsPersist( } } }) - return computer ? computer(ctx, state) : state + if (computer) { + const { pubs } = ctx.cause + + const isInit = pubs.length === 0 + const hasOtherDeps = pubs.length > 1 + + if ( + isInit || + (hasOtherDeps && + pubs.some( + (pub, i) => + i !== 0 && + !Object.is( + pub.state, + // @ts-expect-error + ctx.get({ __reatom: pub.proto }), + ), + )) + ) { + state = computer(ctx, state) as typeof state + } else { + for (let index = 1; index < pubs.length; index++) { + // @ts-expect-error + ctx.spy({ __reatom: pubs[index]!.proto }) + } + } + } + return state } theAtom.onChange((ctx, state) => { const value = serialize(state) diff --git a/packages/utils/src/index.test.ts b/packages/utils/src/index.test.ts index 732e3a961..e4ce6d4ee 100644 --- a/packages/utils/src/index.test.ts +++ b/packages/utils/src/index.test.ts @@ -1,15 +1,11 @@ import { test } from 'uvu' import * as assert from 'uvu/assert' -import { isDeepEqual, toAbortError } from './' +import { isDeepEqual, toAbortError, toStringKey, random, mockRandom } from './' test('isDeepEqual Set', () => { - assert.ok( - isDeepEqual(new Set([{ a: 1 }, { a: 2 }]), new Set([{ a: 1 }, { a: 2 }])), - ) - assert.not.ok( - isDeepEqual(new Set([{ a: 1 }, { a: 2 }]), new Set([{ a: 2 }, { a: 1 }])), - ) + assert.ok(isDeepEqual(new Set([{ a: 1 }, { a: 2 }]), new Set([{ a: 1 }, { a: 2 }]))) + assert.not.ok(isDeepEqual(new Set([{ a: 1 }, { a: 2 }]), new Set([{ a: 2 }, { a: 1 }]))) ;('👍') //? }) @@ -56,4 +52,42 @@ test('toAbortError', () => { ;('👍') //? }) +test('toStringKey', () => { + const CLASS = new AbortController() + + const obj: Record = {} + obj.obj = obj + obj.class = { CLASS, class: { CLASS } } + obj.list = [ + Object.create(null), + undefined, + false, + true, + 0, + '0', + Symbol('0'), + Symbol.for('0'), + 0n, + () => 0, + new Map([['key', 'val']]), + Object.assign(new Date(0), { + toString(this: Date) { + return this.toISOString() + }, + }), + /regexp/, + ] + + const target = `[reatom Object#1][reatom Array#2][reatom string]class[reatom Object#3][reatom Array#4][reatom string]class[reatom Object#5][reatom Array#6][reatom string]CLASS[reatom AbortController#7][reatom Array#8][reatom string]CLASS[reatom AbortController#7][reatom Array#9][reatom string]list[reatom Array#10][reatom Object#11][reatom undefined]undefined[reatom boolean]false[reatom boolean]true[reatom number]0[reatom string]0[reatom Symbol]0[reatom Symbol]0[reatom bigint]0[reatom Function#12][reatom Map#13][reatom Array#14][reatom string]key[reatom string]val[reatom object]1970-01-01T00:00:00.000Z[reatom object]/regexp/[reatom Array#15][reatom string]obj[reatom Object#1]` + + let i = 1 + const unmock = mockRandom(() => i++) + + assert.is(toStringKey(obj), target) + assert.is(toStringKey(obj), toStringKey(obj)) + + unmock() + assert.is(toStringKey(obj), toStringKey(obj)) +}) + test.run() diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 1822d9103..2ea40d935 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -157,8 +157,18 @@ export const omit = (target: T, keys: Array): Plain(value: T): T => JSON.parse(JSON.stringify(value)) +let _random = (min = 0, max = Number.MAX_SAFE_INTEGER - 1) => Math.floor(Math.random() * (max - min + 1)) + min /** Get random integer. Parameters should be integers too. */ -export const random = (min = 0, max = Number.MAX_SAFE_INTEGER - 1) => Math.floor(Math.random() * (max - min + 1)) + min +export const random: typeof _random = (min, max) => _random(min, max) + +/** Pass a callback to replace the exported random function. Returned function restores the original random behavior. */ +export const mockRandom = (fn: typeof random) => { + const origin = _random + _random = fn + return () => { + _random = origin + } +} /** * Asserts that the value is not `null` or `undefined`. @@ -171,6 +181,7 @@ export const nonNullable = (value: T, message?: string): NonNullable => { } const { toString } = Object.prototype +const { toString: toStringArray } = [] const visited = new WeakMap<{}, string>() /** Stringify any kind of data with some sort of stability. * Support: an object keys sorting, `Map`, `Set`, circular references, custom classes, functions and symbols. @@ -178,28 +189,43 @@ const visited = new WeakMap<{}, string>() */ export const toStringKey = (thing: any, immutable = true): string => { var tag = typeof thing - var isNominal = tag === 'function' || tag === 'symbol' - if (!isNominal && (tag !== 'object' || thing === null || thing instanceof Date || thing instanceof RegExp)) { - return tag + thing + if (tag === 'symbol') return `[reatom Symbol]${thing.description || 'symbol'}` + + if (tag !== 'function' && (tag !== 'object' || thing === null || thing instanceof Date || thing instanceof RegExp)) { + return `[reatom ${tag}]` + thing } if (visited.has(thing)) return visited.get(thing)! + var name = Reflect.getPrototypeOf(thing)?.constructor.name || toString.call(thing).slice(8, -1) // get a unique prefix for each type to separate same array / map - var result = toString.call(thing) - var unique = result + random() // thing could be a circular or not stringifiable object from a userspace - visited.set(thing, unique) - - if (isNominal || (thing.constructor !== Object && Symbol.iterator in thing === false)) { - return unique + var result = `[reatom ${name}#${random()}]` + if (tag === 'function') { + visited.set(thing, (result += thing.name)) + return result + } + visited.set(thing, result) + + var proto = Reflect.getPrototypeOf(thing) + if ( + proto && + Reflect.getPrototypeOf(proto) && + thing.toString !== toStringArray && + Symbol.iterator in thing === false + ) { + return result } - for (let item of Symbol.iterator in thing ? thing : Object.entries(thing).sort(([a], [b]) => a.localeCompare(b))) - result += toStringKey(item, immutable) + var iterator = Symbol.iterator in thing ? thing : Object.entries(thing).sort(([a], [b]) => a.localeCompare(b)) + for (let item of iterator) result += toStringKey(item, immutable) - immutable ? visited.set(thing, result) : visited.delete(thing) + if (immutable) { + visited.set(thing, result) + } else { + visited.delete(thing) + } return result }