diff --git a/README.md b/README.md index c2325eb..343dec5 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,30 @@ export default function App() { } ``` +### 🎭 Isolated State + +Need to run multiple independent instances of your application or isolate features? Use `StateScopeProvider`. + +```tsx +import { AutoRootCtx, StateScopeProvider } from 'react-state-custom' + +function App() { + return ( + <> + {/* Global Scope */} + + + + {/* Isolated Scope - Stores here are independent of Global Scope */} + + + + ) +} +``` + +Stores used inside `StateScopeProvider` will be completely isolated from the parent or global scope, even if they share the same store definition. + --- ## 🆚 Comparison diff --git a/src/index.ts b/src/index.ts index dcd2416..88aa5c1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,7 +13,7 @@ export { } from "./state-utils/ctx" export { createRootCtx } from "./state-utils/createRootCtx" -export { AutoRootCtx, createAutoCtx, createStore } from "./state-utils/createAutoCtx" +export { AutoRootCtx, createAutoCtx, createStore, StateScopeProvider } from "./state-utils/createAutoCtx" export { useArrayChangeId } from "./state-utils/useArrayChangeId" export { paramsToId, type ParamsToIdRecord, type ParamsToIdInput } from "./state-utils/paramsToId" diff --git a/src/state-utils/createAutoCtx.tsx b/src/state-utils/createAutoCtx.tsx index 7aa3cba..20ef6e6 100644 --- a/src/state-utils/createAutoCtx.tsx +++ b/src/state-utils/createAutoCtx.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState, Fragment, useCallback, useMemo, Activity } from "react" -import { useDataContext, useDataSourceMultiple, useDataSubscribe, type Context } from "./ctx" +import { useEffect, useState, Fragment, useCallback, useMemo, useId } from "react" +import { useDataContext, useDataSourceMultiple, useDataSubscribe, StateScopeContext, type Context } from "./ctx" import { createRootCtx } from "./createRootCtx" import { paramsToId, type ParamsToIdRecord } from "./paramsToId" import { useQuickSubscribe } from "./useQuickSubscribe" @@ -183,3 +183,15 @@ export const createStore = { return createAutoCtx(createRootCtx(name, useFn), timeToClean, AttatchedComponent) } + +export const StateScopeProvider: React.FC<{ + children: React.ReactNode + Wrapper?: React.FC + debugging?: boolean +}> = ({ children, Wrapper, debugging }) => { + const scopeId = useId() + return + + {children} + +} diff --git a/src/state-utils/createRootCtx.tsx b/src/state-utils/createRootCtx.tsx index fed42ff..15e5dfa 100644 --- a/src/state-utils/createRootCtx.tsx +++ b/src/state-utils/createRootCtx.tsx @@ -1,5 +1,5 @@ -import { useEffect, useMemo } from "react" -import { useDataContext, useDataSourceMultiple, type Context } from "./ctx" +import { useContext, useEffect, useMemo } from "react" +import { useDataContext, useDataSourceMultiple, StateScopeContext, type Context } from "./ctx" import { paramsToId, type ParamsToIdRecord } from "./paramsToId" import { DependencyTracker } from "./utils" // import { debugObjTime } from "./debugObjTime" @@ -48,9 +48,11 @@ export const createRootCtx = { const ctxName = getCtxName(e) + const scopeId = useContext(StateScopeContext) + const scopedCtxName = scopeId ? `${scopeId}/${ctxName}` : ctxName const ctx = useDataContext(ctxName) - DependencyTracker.enter(ctxName); + DependencyTracker.enter(scopedCtxName); let state; try { state = useFn(e, { ...ctx.data }) @@ -66,13 +68,13 @@ export const createRootCtx = { - if (ctxMountedCheck.has(ctxName)) { - const err = new Error("RootContext " + ctxName + " are mounted more than once") + if (ctxMountedCheck.has(scopedCtxName)) { + const err = new Error("RootContext " + scopedCtxName + " are mounted more than once") err.stack = stack; throw err } - ctxMountedCheck.add(ctxName) - return () => { ctxMountedCheck.delete(ctxName) }; + ctxMountedCheck.add(scopedCtxName) + return () => { ctxMountedCheck.delete(scopedCtxName) }; }) return state; @@ -100,16 +102,18 @@ export const createRootCtx = => { const ctxName = getCtxName(e) + const scopeId = useContext(StateScopeContext) + const scopedCtxName = scopeId ? `${scopeId}/${ctxName}` : ctxName const stack = useMemo(() => new Error().stack, []) useEffect(() => { - if (!ctxMountedCheck.has(ctxName)) { - const err = new Error("RootContext [" + ctxName + "] is not mounted") + if (!ctxMountedCheck.has(scopedCtxName)) { + const err = new Error("RootContext [" + scopedCtxName + "] is not mounted") err.stack = stack; throw err } - }, [ctxName]) + }, [scopedCtxName]) return useDataContext(ctxName) }, @@ -119,17 +123,19 @@ export const createRootCtx = => { const ctxName = getCtxName(e) + const scopeId = useContext(StateScopeContext) + const scopedCtxName = scopeId ? `${scopeId}/${ctxName}` : ctxName const stack = useMemo(() => new Error().stack, []) useEffect(() => { - if (!ctxMountedCheck.has(ctxName)) { - const err = new Error("RootContext [" + ctxName + "] is not mounted") + if (!ctxMountedCheck.has(scopedCtxName)) { + const err = new Error("RootContext [" + scopedCtxName + "] is not mounted") err.stack = stack; let timeout = setTimeout(() => console.error(err), 1000) return () => clearTimeout(timeout) } - }, [ctxMountedCheck.has(ctxName)]) + }, [ctxMountedCheck.has(scopedCtxName)]) return useDataContext(ctxName) } diff --git a/src/state-utils/ctx.ts b/src/state-utils/ctx.ts index 073847b..7169df4 100644 --- a/src/state-utils/ctx.ts +++ b/src/state-utils/ctx.ts @@ -1,9 +1,11 @@ import { debounce, memoize, DependencyTracker } from "./utils"; -import { useEffect, useMemo, useState } from "react" +import { createContext, useContext, useEffect, useMemo, useState } from "react" import { useArrayChangeId } from "./useArrayChangeId" +export const StateScopeContext = createContext(null) + const CHANGE_EVENT = "@--change-event" class DataEvent extends Event { @@ -119,8 +121,10 @@ export type getContext = (e: string) => Context * @returns The Context instance. */ export const useDataContext = (name: string = "noname") => { - DependencyTracker.addDependency(name); - const ctx = useMemo(() => getContext(name), [name]) + const scopeId = useContext(StateScopeContext) + const namespacedName = scopeId ? `${scopeId}/${name}` : name + DependencyTracker.addDependency(namespacedName); + const ctx = useMemo(() => getContext(namespacedName), [namespacedName]) useEffect(() => { ctx.useCounter += 1; return () => { @@ -128,7 +132,7 @@ export const useDataContext = (name: string = "noname") => { if (ctx.useCounter <= 0) { setTimeout(() => { if (ctx.useCounter <= 0) { - getContext.cache.delete(JSON.stringify([name])) + getContext.cache.delete(JSON.stringify([namespacedName])) } }, 100) } diff --git a/tests/createAutoCtx.nested.test.tsx b/tests/createAutoCtx.nested.test.tsx new file mode 100644 index 0000000..95fbef9 --- /dev/null +++ b/tests/createAutoCtx.nested.test.tsx @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest' +import { render, screen, waitFor, fireEvent } from '@testing-library/react' +import React from 'react' +import { createAutoCtx, AutoRootCtx, StateScopeProvider } from '../src/state-utils/createAutoCtx' +import { createRootCtx } from '../src/state-utils/createRootCtx' +import { useDataSubscribe } from '../src/state-utils/ctx' + +describe('AutoRootCtx Isolation', () => { + it('should isolate state between nested scopes', async () => { + // Create a simple counter store + const useCounter = () => { + const [count, setCount] = React.useState(0) + return { + count, + increment: () => setCount(c => c + 1) + } + } + + const rootCtx = createRootCtx('isolation-test', useCounter) + const autoCtx = createAutoCtx(rootCtx) + + function Consumer({ label }: { label: string }) { + const ctx = autoCtx.useCtxState({}) + const count = useDataSubscribe(ctx, 'count') + const increment = useDataSubscribe(ctx, 'increment') + + return ( +
+ {count} + +
+ ) + } + + render( + <> + + + + + + + + ) + + // Initial state: both should be 0 + await waitFor(() => { + expect(screen.getByTestId('count-outer').textContent).toBe('0') + expect(screen.getByTestId('count-inner').textContent).toBe('0') + }) + + // Increment outer + fireEvent.click(screen.getByTestId('btn-outer')) + + await waitFor(() => { + expect(screen.getByTestId('count-outer').textContent).toBe('1') + // Inner should remain 0 if isolated + expect(screen.getByTestId('count-inner').textContent).toBe('0') + }) + + // Increment inner + fireEvent.click(screen.getByTestId('btn-inner')) + + await waitFor(() => { + expect(screen.getByTestId('count-outer').textContent).toBe('1') + expect(screen.getByTestId('count-inner').textContent).toBe('1') + }) + }) +})