-
Notifications
You must be signed in to change notification settings - Fork 0
feat: Support isolated nested state via StateScopeProvider #21
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0fd7053
971759d
d52f817
9149659
22d8ea8
8070f43
a2088ba
fc8b06e
f5b9c1a
0d5b434
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = <U extends ParamsToIdRecord, V extends Record<strin | |
|
|
||
| const useRootState = (e: U) => { | ||
| const ctxName = getCtxName(e) | ||
| const scopeId = useContext(StateScopeContext) | ||
| const scopedCtxName = scopeId ? `${scopeId}/${ctxName}` : ctxName | ||
| const ctx = useDataContext<V>(ctxName) | ||
|
|
||
| DependencyTracker.enter(ctxName); | ||
| DependencyTracker.enter(scopedCtxName); | ||
| let state; | ||
| try { | ||
| state = useFn(e, { ...ctx.data }) | ||
|
|
@@ -66,13 +68,13 @@ export const createRootCtx = <U extends ParamsToIdRecord, V extends Record<strin | |
| ) | ||
|
|
||
| useEffect(() => { | ||
| 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 = <U extends ParamsToIdRecord, V extends Record<strin | |
| */ | ||
| useCtxStateStrict: (e: U): Context<V> => { | ||
| 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<V>(ctxName) | ||
| }, | ||
|
|
@@ -119,17 +123,19 @@ export const createRootCtx = <U extends ParamsToIdRecord, V extends Record<strin | |
| */ | ||
| useCtxState: (e: U): Context<V> => { | ||
| 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<V>(ctxName) | ||
|
Comment on lines
136
to
140
|
||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <div data-testid={`consumer-${label}`}> | ||
| <span data-testid={`count-${label}`}>{count}</span> | ||
| <button onClick={increment} data-testid={`btn-${label}`}> | ||
| Increment | ||
| </button> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| render( | ||
| <> | ||
| <AutoRootCtx /> | ||
| <Consumer label="outer" /> | ||
|
|
||
| <StateScopeProvider> | ||
| <Consumer label="inner" /> | ||
| </StateScopeProvider> | ||
| </> | ||
| ) | ||
|
|
||
| // 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') | ||
| }) | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The README example imports
StateScopeProviderfromreact-state-custom, but it is not currently re-exported from the package entrypoint (src/index.ts). Consumers following this snippet will get a missing export error; please re-exportStateScopeProviderfrom the public index (or adjust the docs if it’s intentionally internal).There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@copilot re-export it