diff --git a/.changeset/yellow-carrots-play.md b/.changeset/yellow-carrots-play.md new file mode 100644 index 000000000..4a915dbce --- /dev/null +++ b/.changeset/yellow-carrots-play.md @@ -0,0 +1,7 @@ +--- +"@suspensive/react-await": patch +"@suspensive/react-image": patch +"@suspensive/react": patch +--- + +fix(*): add use-sync-external-store diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02a086c4b..101d710be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - command: ['lint', 'lint:attw', 'lint:monorepo', 'lint:pub', 'test', 'type:check', 'build'] + command: ['lint', 'lint:attw', 'lint:monorepo', 'lint:pub', 'test', 'test:production', 'type:check', 'build'] steps: - uses: actions/checkout@v4 - uses: ./.github/actions/pnpm-setup-node diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index bd86cfcd5..07e0c0839 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -22,7 +22,7 @@ jobs: - uses: ./.github/actions/pnpm-setup-node - run: pnpm install --frozen-lockfile - run: pnpm playwright install - - run: pnpm test + - run: pnpm test:production && pnpm test - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v3 env: diff --git a/package.json b/package.json index 80e540ab6..b96f756ad 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "changeset": "changeset", "changeset:publish": "pnpm prepack && changeset publish", "changeset:version": "changeset version && pnpm i --lockfile-only", - "ci:all": "pnpm lint && pnpm lint:attw && pnpm lint:monorepo && pnpm lint:pub && pnpm test && pnpm type:check && pnpm build", + "ci:all": "pnpm lint && pnpm lint:attw && pnpm lint:monorepo && pnpm lint:pub && pnpm test && pnpm test:production && pnpm type:check && pnpm build", "clean": "pnpm --filter \"./packages/**\" run clean", "commit": "cz", "dev": "turbo run dev", @@ -45,6 +45,8 @@ "prepare": "husky install", "test": "turbo run test", "test:watch": "turbo run test:watch --parallel", + "test:production": "turbo run test:production", + "test:production:watch": "turbo run test:production:watch --parallel", "type:check": "turbo run type:check" }, "devDependencies": { @@ -59,6 +61,7 @@ "@testing-library/dom": "^9.3.4", "@testing-library/jest-dom": "^6.2.0", "@testing-library/react": "^14.1.2", + "@testing-library/user-event": "^14.5.2", "@types/node": "^18.19.8", "@vitest/browser": "^1.2.1", "@vitest/coverage-istanbul": "^1.2.1", diff --git a/packages/react-await/src/Await.tsx b/packages/react-await/src/Await.tsx index 48b377f04..f6feccb35 100644 --- a/packages/react-await/src/Await.tsx +++ b/packages/react-await/src/Await.tsx @@ -1,5 +1,5 @@ import { type FunctionComponent, useMemo } from 'react' -import { useSyncExternalStore } from 'use-sync-external-store/shim' +import { useSyncExternalStore } from 'use-sync-external-store/shim/index.js' import type { Tuple } from './utility-types' import { hashKey } from './utils' diff --git a/packages/react-image/src/Load.tsx b/packages/react-image/src/Load.tsx index ff12e8765..23af0cbab 100644 --- a/packages/react-image/src/Load.tsx +++ b/packages/react-image/src/Load.tsx @@ -1,5 +1,5 @@ import type { FunctionComponent } from 'react' -import { useSyncExternalStore } from 'use-sync-external-store/shim' +import { useSyncExternalStore } from 'use-sync-external-store/shim/index.js' /** * Loads an image from the given source URL. diff --git a/packages/react-query/src/QueryAsyncBoundary.tsx b/packages/react-query/src/QueryAsyncBoundary.tsx index f6fb7ee0c..bb9a294ee 100644 --- a/packages/react-query/src/QueryAsyncBoundary.tsx +++ b/packages/react-query/src/QueryAsyncBoundary.tsx @@ -18,7 +18,7 @@ const BaseQueryAsyncBoundary = forwardRef< /> ) }) -if (process.env.NODE_ENV !== 'production') { +if (process.env.NODE_ENV === 'development') { BaseQueryAsyncBoundary.displayName = 'QueryAsyncBoundary' } const CSROnly = forwardRef< @@ -37,7 +37,7 @@ const CSROnly = forwardRef< /> ) }) -if (process.env.NODE_ENV !== 'production') { +if (process.env.NODE_ENV === 'development') { CSROnly.displayName = 'QueryAsyncBoundary.CSROnly' } diff --git a/packages/react-query/src/QueryErrorBoundary.tsx b/packages/react-query/src/QueryErrorBoundary.tsx index 2b5b34c4e..84fcd1743 100644 --- a/packages/react-query/src/QueryErrorBoundary.tsx +++ b/packages/react-query/src/QueryErrorBoundary.tsx @@ -37,6 +37,6 @@ export const QueryErrorBoundary = forwardRef< /> ) }) -if (process.env.NODE_ENV !== 'production') { +if (process.env.NODE_ENV === 'development') { QueryErrorBoundary.displayName = 'QueryErrorBoundary' } diff --git a/packages/react/package.json b/packages/react/package.json index e0cfb2421..ad305b28d 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -54,14 +54,20 @@ "prepack": "pnpm build", "test": "vitest run --coverage --typecheck", "test:watch": "vitest --ui --coverage --typecheck", + "test:production": "vitest run --config ./vitest-production.config.ts", + "test:production:watch": "vitest --config ./vitest-production.config.ts --ui", "type:check": "tsc --noEmit" }, + "dependencies": { + "use-sync-external-store": "^1.2.0" + }, "devDependencies": { "@suspensive/package-json-name": "workspace:*", "@suspensive/test-utils": "workspace:*", "@suspensive/tsup": "workspace:*", "@types/react": "^18.2.48", "@types/react-dom": "^18.2.18", + "@types/use-sync-external-store": "^0.0.6", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/packages/react/src/AsyncBoundary.tsx b/packages/react/src/AsyncBoundary.tsx index 250a14928..c74de8178 100644 --- a/packages/react/src/AsyncBoundary.tsx +++ b/packages/react/src/AsyncBoundary.tsx @@ -27,7 +27,7 @@ export const AsyncBoundary = Object.assign( ) ) - if (process.env.NODE_ENV !== 'production') { + if (process.env.NODE_ENV === 'development') { BaseAsyncBoundary.displayName = 'AsyncBoundary' } @@ -47,7 +47,7 @@ export const AsyncBoundary = Object.assign( ) ) - if (process.env.NODE_ENV !== 'production') { + if (process.env.NODE_ENV === 'development') { CSROnly.displayName = 'AsyncBoundary.CSROnly' } diff --git a/packages/react/src/Delay.tsx b/packages/react/src/Delay.tsx index e60c6b2af..9b5435145 100644 --- a/packages/react/src/Delay.tsx +++ b/packages/react/src/Delay.tsx @@ -13,7 +13,7 @@ export interface DelayProps extends PropsWithChildren { } export const Delay = (props: DelayProps) => { - if (process.env.NODE_ENV !== 'production') { + if (process.env.NODE_ENV === 'development') { if (typeof props.ms === 'number') { assert(props.ms >= 0, DelayMsPropShouldBeGreaterThanOrEqualTo0) } @@ -27,6 +27,6 @@ export const Delay = (props: DelayProps) => { const fallback = typeof props.fallback === 'undefined' ? defaultProps.fallback : props.fallback return isDelaying ? fallback : props.children } -if (process.env.NODE_ENV !== 'production') { +if (process.env.NODE_ENV === 'development') { Delay.displayName = 'Delay' } diff --git a/packages/react/src/DevMode.production.spec.tsx b/packages/react/src/DevMode.production.spec.tsx new file mode 100644 index 000000000..e12d16d7f --- /dev/null +++ b/packages/react/src/DevMode.production.spec.tsx @@ -0,0 +1,23 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { DevMode } from './DevMode' +import { Suspensive, SuspensiveProvider } from './Suspensive' + +describe(' (process.env.NODE_ENV: production)', () => { + it('should show nothing if without SuspensiveProvider', () => { + render() + expect(screen.queryByRole('Suspensive.DevMode-off')).not.toBeInTheDocument() + expect(screen.queryByRole('Suspensive.DevMode-on')).not.toBeInTheDocument() + }) + + it('should show nothing on production mode in SuspensiveProvider', () => { + const suspensive = new Suspensive() + const renderResult = render( + + + + ) + + expect(renderResult.queryByRole('Suspensive.DevMode-off')).not.toBeInTheDocument() + }) +}) diff --git a/packages/react/src/DevMode.spec.tsx b/packages/react/src/DevMode.spec.tsx index 9fc9b58f7..bdb56a10e 100644 --- a/packages/react/src/DevMode.spec.tsx +++ b/packages/react/src/DevMode.spec.tsx @@ -1,50 +1,36 @@ -import { fireEvent, render } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' import { DevMode } from './DevMode' import { ErrorBoundary } from './ErrorBoundary' import { Suspense } from './Suspense' import { Suspensive, SuspensiveProvider } from './Suspensive' -describe('', () => { +describe(' (process.env.NODE_ENV: development)', () => { it('should show nothing if without SuspensiveProvider', () => { - const renderResult = render() - expect(renderResult.queryByRole('Suspensive.DevMode-off')).not.toBeInTheDocument() - expect(renderResult.queryByRole('Suspensive.DevMode-on')).not.toBeInTheDocument() + render() + expect(screen.queryByRole('Suspensive.DevMode-off')).not.toBeInTheDocument() + expect(screen.queryByRole('Suspensive.DevMode-on')).not.toBeInTheDocument() }) it('should show DevMode with role `Suspensive.DevMode: off` in SuspensiveProvider', () => { const suspensive = new Suspensive() - const renderResult = render( + render( ) - - expect(renderResult.getByRole('Suspensive.DevMode-off')).toBeInTheDocument() - }) - - it('should show nothing on production mode in SuspensiveProvider', () => { - process.env.NODE_ENV = 'production' - const suspensive = new Suspensive() - const renderResult = render( - - - - ) - - expect(renderResult.queryByText('Suspensive.DevMode-off')).not.toBeInTheDocument() - process.env.NODE_ENV = undefined + expect(screen.getByRole('Suspensive.DevMode-off')).toBeInTheDocument() }) it('should scale up itself if hover', () => { const suspensive = new Suspensive() - const renderResult = render( + render( ) - const devMode = renderResult.getByRole('Suspensive.DevMode-off') + const devMode = screen.getByRole('Suspensive.DevMode-off') expect(devMode).toBeInTheDocument() expect(devMode.style.transform).toBe('scale(1)') fireEvent.mouseEnter(devMode) @@ -55,13 +41,13 @@ describe('', () => { it('should show Suspensive logo with opacity 100% if clicked', () => { const suspensive = new Suspensive() - const renderResult = render( + render( ) - const devMode = renderResult.getByRole('Suspensive.DevMode-off') + const devMode = screen.getByRole('Suspensive.DevMode-off') expect(devMode).toBeInTheDocument() fireEvent.click(devMode) expect(devMode.style.opacity).toBe('1') @@ -69,7 +55,7 @@ describe('', () => { it('should make Suspense with clientOnly prop show fallback if DevMode is clicked once', () => { const suspensive = new Suspensive() - const renderResult = render( + render( ', () => { ) - const devMode = renderResult.getByRole('Suspensive.DevMode-off') + const devMode = screen.getByRole('Suspensive.DevMode-off') expect(devMode).toBeInTheDocument() fireEvent.click(devMode) expect(devMode.style.opacity).toBe('1') - expect(renderResult.getByText('loading...')).toBeInTheDocument() + expect(screen.getByText('loading...')).toBeInTheDocument() }) it('should make Suspense with clientOnly prop as no devMode if devMode prop is just object', () => { const suspensive = new Suspensive() - const renderResult = render( + render( children @@ -101,15 +87,15 @@ describe('', () => { ) - const devMode = renderResult.getByRole('Suspensive.DevMode-off') + const devMode = screen.getByRole('Suspensive.DevMode-off') expect(devMode).toBeInTheDocument() fireEvent.click(devMode) expect(devMode.style.opacity).toBe('1') - expect(renderResult.getByText('children')).toBeInTheDocument() + expect(screen.getByText('children')).toBeInTheDocument() }) it('should make Suspense show fallback if DevMode is clicked once', () => { const suspensive = new Suspensive() - const renderResult = render( + render( ', () => { ) - const devMode = renderResult.getByRole('Suspensive.DevMode-off') + const devMode = screen.getByRole('Suspensive.DevMode-off') expect(devMode).toBeInTheDocument() fireEvent.click(devMode) expect(devMode.style.opacity).toBe('1') - expect(renderResult.getByText('loading...')).toBeInTheDocument() + expect(screen.getByText('loading...')).toBeInTheDocument() }) it('should make Suspense as no devMode if devMode prop is just object', () => { const suspensive = new Suspensive() - const renderResult = render( + render( children @@ -140,16 +126,16 @@ describe('', () => { ) - const devMode = renderResult.getByRole('Suspensive.DevMode-off') + const devMode = screen.getByRole('Suspensive.DevMode-off') expect(devMode).toBeInTheDocument() fireEvent.click(devMode) expect(devMode.style.opacity).toBe('1') - expect(renderResult.getByText('children')).toBeInTheDocument() + expect(screen.getByText('children')).toBeInTheDocument() }) it('should make ErrorBoundary show fallback if DevMode is clicked once', () => { const suspensive = new Suspensive() - const renderResult = render( + render( ', () => { ) - const devMode = renderResult.getByRole('Suspensive.DevMode-off') + const devMode = screen.getByRole('Suspensive.DevMode-off') expect(devMode).toBeInTheDocument() fireEvent.click(devMode) expect(devMode.style.opacity).toBe('1') - expect(renderResult.getByText('errorBoundary fallback')).toBeInTheDocument() + expect(screen.getByText('errorBoundary fallback')).toBeInTheDocument() }) it('should make ErrorBoundary as no devMode if devMode prop is just object', () => { const suspensive = new Suspensive() - const renderResult = render( + render( children @@ -180,10 +166,10 @@ describe('', () => { ) - const devMode = renderResult.getByRole('Suspensive.DevMode-off') + const devMode = screen.getByRole('Suspensive.DevMode-off') expect(devMode).toBeInTheDocument() fireEvent.click(devMode) expect(devMode.style.opacity).toBe('1') - expect(renderResult.getByText('children')).toBeInTheDocument() + expect(screen.getByText('children')).toBeInTheDocument() }) }) diff --git a/packages/react/src/DevMode.tsx b/packages/react/src/DevMode.tsx index 133617a1d..fde8a2fec 100644 --- a/packages/react/src/DevMode.tsx +++ b/packages/react/src/DevMode.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { useDevModeObserve } from './contexts' +import { syncDevMode } from './contexts' const Position = { bottomLeft: { bottom: 8, left: 8 }, @@ -16,426 +16,418 @@ interface DevModeProps { /** * @experimental This is experimental feature. */ -export const DevMode = ({ position = 'bottomRight' }: DevModeProps) => { - if (process.env.NODE_ENV !== 'production') { - return - } - return null -} - -const ModeSubscriber = ({ position }: { position: keyof typeof Position }) => { - const [isHover, setIsHover] = useState(false) - const devMode = useDevModeObserve() - if (process.env.NODE_ENV !== 'production' && devMode != null) { - return ( - setIsHover(true)} - onMouseLeave={() => setIsHover(false)} - style={{ - backgroundColor: 'black', - position: 'fixed', - border: '1px solid #ffffff60', - borderRadius: '50%', - cursor: 'pointer', - color: 'white', - fontWeight: 900, - transition: 'all 200ms', - ...Position[position], - opacity: devMode.is ? 1 : isHover ? 0.4 : 0.2, - transform: isHover ? 'scale(1.1)' : 'scale(1)', - width: 54, - height: 54, - }} - viewBox="0 0 700 700" - fill="none" - xmlns="http://www.w3.org/2000/svg" - > - - - - - - - - - - - - - - - - (({ devMode, position = 'bottomRight' }) => { + const [isHover, setIsHover] = useState(false) + return ( + setIsHover(true)} + onMouseLeave={() => setIsHover(false)} + style={{ + backgroundColor: 'black', + position: 'fixed', + border: '1px solid #ffffff60', + borderRadius: '50%', + cursor: 'pointer', + color: 'white', + fontWeight: 900, + transition: 'all 200ms', + ...Position[position], + opacity: devMode.is ? 1 : isHover ? 0.4 : 0.2, + transform: isHover ? 'scale(1.1)' : 'scale(1)', + width: 54, + height: 54, + }} + viewBox="0 0 700 700" + fill="none" + xmlns="http://www.w3.org/2000/svg" > - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ) - } - return null -} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) + }) + : () => null diff --git a/packages/react/src/ErrorBoundary.tsx b/packages/react/src/ErrorBoundary.tsx index a53a433b7..eb6211eb3 100644 --- a/packages/react/src/ErrorBoundary.tsx +++ b/packages/react/src/ErrorBoundary.tsx @@ -12,7 +12,7 @@ import { useRef, useState, } from 'react' -import { useDevModeObserve } from './contexts' +import { syncDevMode } from './contexts' import { Delay } from './Delay' import { ErrorBoundaryGroupContext } from './ErrorBoundaryGroup' import type { ConstructorType, PropsWithDevMode } from './utility-types' @@ -69,7 +69,7 @@ export type ErrorBoundaryProps = PropsWithDevMode< */ shouldCatch?: ShouldCatch | [ShouldCatch, ...ShouldCatch[]] }>, - ErrorBoundaryDevModeOptions + ErrorBoundaryDevModeProp > type ErrorBoundaryState = @@ -118,7 +118,7 @@ class BaseErrorBoundary extends Component {children} - {process.env.NODE_ENV !== 'production' && devMode && } + ) } ) - if (process.env.NODE_ENV !== 'production') { + + if (process.env.NODE_ENV === 'development') { ErrorBoundary.displayName = 'ErrorBoundary' } @@ -182,7 +183,7 @@ export const ErrorBoundary = Object.assign( ) const ErrorBoundaryContext = createContext<({ reset: () => void } & ErrorBoundaryState) | null>(null) -if (process.env.NODE_ENV !== 'production') { +if (process.env.NODE_ENV === 'development') { ErrorBoundaryContext.displayName = 'ErrorBoundaryContext' } @@ -228,7 +229,7 @@ export const useErrorBoundaryFallbackProps = (): E /** * @experimental This is experimental feature. */ -type ErrorBoundaryDevModeOptions = { +type ErrorBoundaryDevModeProp = { /** * @experimental This is experimental feature. */ @@ -245,13 +246,11 @@ type ErrorBoundaryDevModeOptions = { after?: number } } -const ErrorBoundaryDevMode = ({ showFallback = false }: ErrorBoundaryDevModeOptions) => { - const devMode = useDevModeObserve() - if (devMode?.is && showFallback) { +const ErrorBoundaryDevMode = syncDevMode(({ devMode, showFallback = false }) => { + if (devMode.is && showFallback) { if (showFallback === true) { showFallback = devModeDefaultErrorBoundaryShowFallback } - return ( @@ -259,8 +258,7 @@ const ErrorBoundaryDevMode = ({ showFallback = false }: ErrorBoundaryDevModeOpti ) } return null -} - +}) const devModeDefaultErrorBoundaryShowFallback = { errorMessage: ` set Error ErrorBoundary`, after: 0, diff --git a/packages/react/src/ErrorBoundaryGroup.tsx b/packages/react/src/ErrorBoundaryGroup.tsx index 902edf180..c71f14b29 100644 --- a/packages/react/src/ErrorBoundaryGroup.tsx +++ b/packages/react/src/ErrorBoundaryGroup.tsx @@ -12,7 +12,7 @@ import { assert, increase } from './utils' import { useErrorBoundaryGroup_this_hook_should_be_called_in_ErrorBoundary_props_children } from './utils/assert' export const ErrorBoundaryGroupContext = createContext<{ reset: () => void; resetKey: number } | undefined>(undefined) -if (process.env.NODE_ENV !== 'production') { +if (process.env.NODE_ENV === 'development') { ErrorBoundaryGroupContext.displayName = 'ErrorBoundaryGroupContext' } @@ -45,7 +45,7 @@ export const ErrorBoundaryGroup = Object.assign( return {children} } - if (process.env.NODE_ENV !== 'production') { + if (process.env.NODE_ENV === 'development') { ErrorBoundaryGroup.displayName = 'ErrorBoundaryGroup' } diff --git a/packages/react/src/Suspense.tsx b/packages/react/src/Suspense.tsx index 6034d8034..662f8dec8 100644 --- a/packages/react/src/Suspense.tsx +++ b/packages/react/src/Suspense.tsx @@ -1,13 +1,12 @@ import { Suspense as ReactSuspense, type SuspenseProps as ReactSuspenseProps, useContext } from 'react' -import { SuspenseDefaultPropsContext, useDevModeObserve } from './contexts' +import { SuspenseDefaultPropsContext, syncDevMode } from './contexts' import { useIsClient } from './hooks' import type { OmitKeyOf, PropsWithDevMode } from './utility-types' -import { noop } from './utils' const SuspenseClientOnly = (props: ReactSuspenseProps) => useIsClient() ? : <>{props.fallback} -export interface SuspenseProps extends PropsWithDevMode { +export interface SuspenseProps extends PropsWithDevMode { /** * With clientOnly prop, `` will return fallback in server but after mount return children in client. Since mount only happens on the client, `` can be avoid server-side rendering. * @see https://suspensive.org/docs/react/Suspense#avoid-server-side-rendering-clientonly @@ -27,11 +26,11 @@ export const Suspense = Object.assign( return ( {children} - {process.env.NODE_ENV !== 'production' && devMode && } + ) } - if (process.env.NODE_ENV !== 'production') { + if (process.env.NODE_ENV === 'development') { Suspense.displayName = 'Suspense' } return Suspense @@ -46,11 +45,11 @@ export const Suspense = Object.assign( return ( {children} - {process.env.NODE_ENV !== 'production' && devMode && } + ) } - if (process.env.NODE_ENV !== 'production') { + if (process.env.NODE_ENV === 'development') { Suspense.displayName = 'Suspense.CSROnly' } return Suspense @@ -58,16 +57,15 @@ export const Suspense = Object.assign( } ) -type SuspenseDevModeOptions = { +type SuspenseDevModeProp = { /** * @experimental This is experimental feature. */ showFallback?: boolean } -const SuspenseDevMode = ({ showFallback = false }: SuspenseDevModeOptions) => { - const devMode = useDevModeObserve() - if (devMode?.is && showFallback) { - throw new Promise(noop) +const SuspenseDevMode = syncDevMode(({ devMode, showFallback }) => { + if (devMode.is && showFallback) { + throw devMode.promise } return null -} +}) diff --git a/packages/react/src/Suspensive.tsx b/packages/react/src/Suspensive.tsx index 5f8950b57..4055b1c79 100644 --- a/packages/react/src/Suspensive.tsx +++ b/packages/react/src/Suspensive.tsx @@ -1,10 +1,5 @@ import { type ContextType, type PropsWithChildren, useMemo } from 'react' -import { - DelayDefaultPropsContext, - SuspenseDefaultPropsContext, - SuspensiveDevMode, - SuspensiveDevModeContext, -} from './contexts' +import { DelayDefaultPropsContext, DevModeContext, SuspenseDefaultPropsContext, SuspensiveDevMode } from './contexts' import { assert } from './utils' import { SuspensiveConfigDefaultOptionsDelayMsShouldBeGreaterThan0 } from './utils/assert' @@ -16,7 +11,7 @@ export class Suspensive { public devMode = new SuspensiveDevMode() constructor(config: { defaultOptions?: Suspensive['defaultOptions'] } = {}) { - if (process.env.NODE_ENV !== 'production') { + if (process.env.NODE_ENV === 'development') { if (typeof config.defaultOptions?.delay?.ms === 'number') { assert(config.defaultOptions.delay.ms > 0, SuspensiveConfigDefaultOptionsDelayMsShouldBeGreaterThan0) } @@ -33,12 +28,12 @@ export const SuspensiveProvider = ({ value, children }: SuspensiveProviderProps) const suspenseDefaultOptions = useMemo(() => value.defaultOptions?.suspense || {}, [value.defaultOptions?.suspense]) return ( - + {children} - + ) } diff --git a/packages/react/src/contexts/DefaultOptionsContexts.ts b/packages/react/src/contexts/DefaultOptionsContexts.ts index 2ceca7133..850618e29 100644 --- a/packages/react/src/contexts/DefaultOptionsContexts.ts +++ b/packages/react/src/contexts/DefaultOptionsContexts.ts @@ -5,7 +5,7 @@ import type { OmitKeyOf } from '../utility-types' export const DelayDefaultPropsContext = createContext>({ ms: undefined, }) -if (process.env.NODE_ENV !== 'production') { +if (process.env.NODE_ENV === 'development') { DelayDefaultPropsContext.displayName = 'DelayDefaultPropsContext' } @@ -13,6 +13,6 @@ export const SuspenseDefaultPropsContext = createContext { + describe('syncDevMode', () => { + it('should make component synced with DevMode in production mode', () => { + const logSpy = vi.spyOn(console, 'log') + render( + createElement( + syncDevMode(({ devMode }) => { + console.log(devMode.is) + return <>{TEXT} + }) + ), + { + wrapper: ({ children }) => ( + + {children} + + + ), + } + ) + expect(screen.queryByText(TEXT)).not.toBeInTheDocument() + expect(logSpy).not.toBeCalled() + process.env.NODE_ENV = undefined + }) + }) +}) diff --git a/packages/react/src/contexts/SuspensiveDevModeContext.spec.tsx b/packages/react/src/contexts/SuspensiveDevModeContext.spec.tsx index 062ac4b14..99d41672b 100644 --- a/packages/react/src/contexts/SuspensiveDevModeContext.spec.tsx +++ b/packages/react/src/contexts/SuspensiveDevModeContext.spec.tsx @@ -1,33 +1,40 @@ -import { renderHook } from '@testing-library/react' -import { describe, expect, it } from 'vitest' +import { TEXT, sleep } from '@suspensive/test-utils' +import { render, renderHook, screen, waitFor } from '@testing-library/react' +import { userEvent } from '@testing-library/user-event' +import { createElement, useContext } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { DevMode } from '../DevMode' import { Suspensive, SuspensiveProvider } from '../Suspensive' -import { SuspensiveDevMode, useDevModeObserve } from './SuspensiveDevModeContext' +import { DevModeContext, SuspensiveDevMode, SuspensiveDevModeOnInfoText, syncDevMode } from './SuspensiveDevModeContext' -describe('useDevModeObserve', () => { - it('should return null if no SuspensiveProvider in parent', () => { - const { result } = renderHook(useDevModeObserve) +describe('SuspensiveDevMode (process.env.NODE_ENV: development)', () => { + it('should have field `is` that can be changed by on off', () => { + const devMode = new SuspensiveDevMode() - expect(result.current).toBeNull() + expect(devMode.is).toBe(false) + devMode.on() + expect(devMode.is).toBe(true) + devMode.off() + expect(devMode.is).toBe(false) }) - it('should return null if SuspensiveProvider in parent', () => { - const suspensive = new Suspensive() - const { result } = renderHook(useDevModeObserve, { - wrapper: (props) => , - }) - expect(result.current).toBeInstanceOf(SuspensiveDevMode) - }) -}) + it('should inform if DevMode is on by console.info', async () => { + const devMode = new SuspensiveDevMode() + const infoSpy = vi.spyOn(console, 'info') -describe('SuspensiveDevMode', () => { - it('should have field `is` that can be changed by on off', () => { - const suspensiveDevMode = new SuspensiveDevMode() - - expect(suspensiveDevMode.is).toBe(false) - suspensiveDevMode.on() - expect(suspensiveDevMode.is).toBe(true) - suspensiveDevMode.off() - expect(suspensiveDevMode.is).toBe(false) + expect(devMode.is).toBe(false) + devMode.on() + expect(devMode.is).toBe(true) + await waitFor(() => expect(infoSpy.mock.calls[0][0]).toBe(SuspensiveDevModeOnInfoText)) + await waitFor(() => expect(infoSpy).toBeCalledTimes(1)) + await waitFor(() => expect(infoSpy).toBeCalledTimes(2)) + await waitFor(() => expect(infoSpy).toBeCalledTimes(3)) + await waitFor(() => expect(infoSpy).toBeCalledTimes(4)) + devMode.off() + await sleep(1000) + expect(infoSpy).toBeCalledTimes(4) + await sleep(1000) + expect(infoSpy).toBeCalledTimes(4) }) it('should notify that devMode have changed to subscribers', () => { @@ -48,4 +55,48 @@ describe('SuspensiveDevMode', () => { suspensiveDevMode.off() expect(subscriber.notifiedCount).toBe(2) }) + + describe('DevModeContext', () => { + it('returns null when no SuspensiveProvider is present', () => { + const { result } = renderHook(() => useContext(DevModeContext)) + expect(result.current).toBeNull() + }) + + it('returns an instance of SuspensiveDevMode when wrapped with SuspensiveProvider', () => { + const { result } = renderHook(() => useContext(DevModeContext), { + wrapper: (props) => , + }) + expect(result.current).toBeInstanceOf(SuspensiveDevMode) + }) + }) + + describe('syncDevMode (process.env.NODE_ENV: development)', () => { + it('should make component synced with DevMode at development', async () => { + const user = userEvent.setup() + const logSpy = vi.spyOn(console, 'log') + render( + createElement( + syncDevMode(({ devMode }) => { + console.log(devMode.is) + return <>{TEXT} + }) + ), + { + wrapper: ({ children }) => ( + + {children} + + + ), + } + ) + // expect(screen.queryByText(TEXT)).toBeInTheDocument() + expect(logSpy.mock.calls[0][0]).toBe(false) + user.click(screen.getByRole('Suspensive.DevMode-off')) + await waitFor(() => expect(logSpy.mock.calls[1][0]).toBe(true)) + user.click(screen.getByRole('Suspensive.DevMode-on')) + await waitFor(() => expect(logSpy.mock.calls[2][0]).toBe(false)) + await waitFor(() => expect(logSpy.mock.calls[3]).toBe(undefined)) + }) + }) }) diff --git a/packages/react/src/contexts/SuspensiveDevModeContext.ts b/packages/react/src/contexts/SuspensiveDevModeContext.ts index fcd99ce48..9e4742185 100644 --- a/packages/react/src/contexts/SuspensiveDevModeContext.ts +++ b/packages/react/src/contexts/SuspensiveDevModeContext.ts @@ -1,42 +1,59 @@ -import { createContext, useContext, useEffect, useReducer } from 'react' -import { increase } from '../utils' +import { type ComponentProps, type ComponentType, createContext, createElement, useContext } from 'react' +// https://github.com/suspensive/react/pull/203 +// https://github.com/TanStack/query/blob/v4/packages/react-query/src/useSyncExternalStore.ts +import { useSyncExternalStore } from 'use-sync-external-store/shim/index.js' +import { Subscribable } from '../models/Subscribable' +import { noop } from '../utils/noop' -export const SuspensiveDevModeContext = createContext(null) -export const useDevModeObserve = () => { - const suspensiveDevMode = useContext(SuspensiveDevModeContext) - const render = useReducer(increase, 0)[1] - useEffect(() => suspensiveDevMode?.subscribe(render), [suspensiveDevMode, render]) +export const DevModeContext = createContext(null) - return suspensiveDevMode -} +type SyncDevMode = >( + Component: ComponentType +) => (props: TProps) => React.FunctionComponentElement< + TProps & { + devMode: SuspensiveDevMode + } +> | null + +export const syncDevMode: SyncDevMode = + process.env.NODE_ENV === 'development' + ? >( + Component: ComponentType + ) => { + const Wrapped = (props: TProps & { devMode: SuspensiveDevMode }) => { + useSyncExternalStore(props.devMode.subscribe, () => props.devMode.is) + return createElement(Component, props) + } + const WrappedWrapped = (props: TProps) => { + const devMode = useContext(DevModeContext) + return devMode ? createElement(Wrapped, { ...props, devMode }) : null + } + return WrappedWrapped + } + : () => () => null + +export const SuspensiveDevModeOnInfoText = '[Suspensive] DevMode is now working' -type Sync = () => void -export class SuspensiveDevMode { - constructor(public is = false) {} - private syncs = new Map() +export class SuspensiveDevMode extends Subscribable { + promise = new Promise(noop) + is = false on = () => { - if (process.env.NODE_ENV !== 'production') { - this.is = true - this.syncSubscribers() - } + this.is = true + this.promise = new Promise((resolve) => { + const timeout = setInterval(() => { + if (this.is) { + return console.info(SuspensiveDevModeOnInfoText, new Date()) + } + resolve() + clearInterval(timeout) + }, 500) + }) + this.notify() } off = () => { - if (process.env.NODE_ENV !== 'production') { - this.is = false - this.syncSubscribers() - } - } - subscribe = (sync: Sync) => { - if (process.env.NODE_ENV !== 'production') { - this.syncs.set(sync, sync) - } - - return () => this.unsubscribe(sync) - } - unsubscribe = (sync: Sync) => { - if (process.env.NODE_ENV !== 'production') { - this.syncs.delete(sync) - } + this.is = false + this.promise = new Promise(noop) + this.notify() } - syncSubscribers = () => this.syncs.forEach((sync) => sync()) + notify = () => this.listeners.forEach((listener) => listener()) } diff --git a/packages/react/src/contexts/index.ts b/packages/react/src/contexts/index.ts index 4e098f4e3..52678eefb 100644 --- a/packages/react/src/contexts/index.ts +++ b/packages/react/src/contexts/index.ts @@ -1,2 +1,2 @@ export { DelayDefaultPropsContext, SuspenseDefaultPropsContext } from './DefaultOptionsContexts' -export { SuspensiveDevMode, useDevModeObserve, SuspensiveDevModeContext } from './SuspensiveDevModeContext' +export { SuspensiveDevMode, DevModeContext, syncDevMode } from './SuspensiveDevModeContext' diff --git a/packages/react/src/models/Subscribable.ts b/packages/react/src/models/Subscribable.ts new file mode 100644 index 000000000..31cee12d7 --- /dev/null +++ b/packages/react/src/models/Subscribable.ts @@ -0,0 +1,18 @@ +type Listener = () => void + +export class Subscribable unknown = Listener> { + listeners: Set + + constructor() { + this.listeners = new Set() + this.subscribe = this.subscribe.bind(this) + } + + subscribe = (listener: TListener) => { + this.listeners.add(listener) + + return () => { + this.listeners.delete(listener) + } + } +} diff --git a/packages/react/src/wrap.tsx b/packages/react/src/wrap.tsx index 573dbbadb..c7a0465bb 100644 --- a/packages/react/src/wrap.tsx +++ b/packages/react/src/wrap.tsx @@ -51,7 +51,7 @@ class WrapWithoutCSROnly { ) - if (process.env.NODE_ENV !== 'production') { + if (process.env.NODE_ENV === 'development') { WrappedComponent.displayName = this.wrappers.reduce( (acc, [WrapperComponent]) => `with${WrapperComponent.displayName}(${acc})`, Component.displayName || Component.name || 'Component' @@ -155,7 +155,7 @@ export const withAsyncBoundary = Object.assign( ) - if (process.env.NODE_ENV !== 'production') { + if (process.env.NODE_ENV === 'development') { const name = Component.displayName || Component.name || 'Component' Wrapped.displayName = `withAsyncBoundary(${name})` } @@ -176,7 +176,7 @@ export const withAsyncBoundary = Object.assign( ) - if (process.env.NODE_ENV !== 'production') { + if (process.env.NODE_ENV === 'development') { const name = Component.displayName || Component.name || 'Component' Wrapped.displayName = `withAsyncBoundary.CSROnly(${name})` } diff --git a/packages/react/vitest-production.config.ts b/packages/react/vitest-production.config.ts new file mode 100644 index 000000000..8cff321b8 --- /dev/null +++ b/packages/react/vitest-production.config.ts @@ -0,0 +1,16 @@ +import { packageJsonName } from '@suspensive/package-json-name' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + name: packageJsonName(), + dir: './src', + include: ['**/*.production.*'], + environment: 'jsdom', + globals: true, + setupFiles: './vitest-production.setup.ts', + coverage: { + provider: 'istanbul', + }, + }, +}) diff --git a/packages/react/vitest-production.setup.ts b/packages/react/vitest-production.setup.ts new file mode 100644 index 000000000..cae12a926 --- /dev/null +++ b/packages/react/vitest-production.setup.ts @@ -0,0 +1,13 @@ +import '@testing-library/jest-dom/vitest' +import { cleanup } from '@testing-library/react' +import { afterEach, vi } from 'vitest' + +// we can't use process.env.NODE_ENV because of react error (in NODE_ENV: production react will use other module) +/** + * TypeError: jsxDEV is not a function + * - /src/contexts/SuspensiveDevModeContext.production.spec.tsx + */ +vi.stubEnv('NODE_ENV', 'production(not development)') +afterEach(() => { + cleanup() +}) diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.ts index 1134c99c1..0e96465a0 100644 --- a/packages/react/vitest.config.ts +++ b/packages/react/vitest.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ test: { name: packageJsonName(), dir: './src', + exclude: ['**/*.production.*'], environment: 'jsdom', globals: true, setupFiles: './vitest.setup.ts', diff --git a/packages/react/vitest.setup.ts b/packages/react/vitest.setup.ts index e87ca3f0a..f07a27021 100644 --- a/packages/react/vitest.setup.ts +++ b/packages/react/vitest.setup.ts @@ -1,7 +1,8 @@ import '@testing-library/jest-dom/vitest' import { cleanup } from '@testing-library/react' -import { afterEach } from 'vitest' +import { afterEach, vi } from 'vitest' +vi.stubEnv('NODE_ENV', 'development') afterEach(() => { cleanup() }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1f8194e2..241360e6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@testing-library/react': specifier: ^14.1.2 version: 14.1.2(react-dom@18.2.0)(react@18.2.0) + '@testing-library/user-event': + specifier: ^14.5.2 + version: 14.5.2(@testing-library/dom@9.3.4) '@types/node': specifier: ^18.19.8 version: 18.19.8 @@ -240,6 +243,10 @@ importers: version: 3.4.1 packages/react: + dependencies: + use-sync-external-store: + specifier: ^1.2.0 + version: 1.2.0(react@18.2.0) devDependencies: '@suspensive/package-json-name': specifier: workspace:* @@ -256,6 +263,9 @@ importers: '@types/react-dom': specifier: ^18.2.18 version: 18.2.18 + '@types/use-sync-external-store': + specifier: ^0.0.6 + version: 0.0.6 react: specifier: ^18.2.0 version: 18.2.0 @@ -2151,6 +2161,15 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: true + /@testing-library/user-event@14.5.2(@testing-library/dom@9.3.4): + resolution: {integrity: sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + dependencies: + '@testing-library/dom': 9.3.4 + dev: true + /@theguild/remark-mermaid@0.0.5(react@18.2.0): resolution: {integrity: sha512-e+ZIyJkEv9jabI4m7q29wZtZv+2iwPGsXJ2d46Zi7e+QcFudiyuqhLhHG/3gX3ZEB+hxTch+fpItyMS8jwbIcw==} peerDependencies: diff --git a/turbo.json b/turbo.json index 7b6990513..727010ac1 100644 --- a/turbo.json +++ b/turbo.json @@ -1,16 +1,53 @@ { "$schema": "https://turbo.build/schema.json", "pipeline": { - "build": { "dependsOn": ["^build"], "outputs": ["dist/**", ".next/**"] }, - "build:watch": { "dependsOn": ["prepack"], "cache": false, "outputs": ["dist/**"] }, - "lint": { "dependsOn": ["prepack"] }, - "lint:attw": { "dependsOn": ["prepack"] }, - "lint:pub": { "dependsOn": ["prepack"] }, - "test": { "dependsOn": ["prepack"], "outputs": ["coverage/**"] }, - "test:watch": { "dependsOn": ["prepack"], "cache": false, "outputs": ["coverage/**"] }, - "prepack": { "dependsOn": ["^prepack"], "outputs": ["dist/**"] }, - "dev": { "dependsOn": ["prepack"], "cache": false }, - "type:check": { "dependsOn": ["prepack"] } + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**", ".next/**"] + }, + "build:watch": { + "dependsOn": ["prepack"], + "cache": false, + "outputs": ["dist/**"] + }, + "lint": { + "dependsOn": ["prepack"] + }, + "lint:attw": { + "dependsOn": ["prepack"] + }, + "lint:pub": { + "dependsOn": ["prepack"] + }, + "test": { + "dependsOn": ["prepack"], + "outputs": ["coverage/**"] + }, + "test:watch": { + "dependsOn": ["prepack"], + "cache": false, + "outputs": ["coverage/**"] + }, + "test:production": { + "dependsOn": ["prepack"], + "outputs": ["coverage/**"] + }, + "test:production:watch": { + "dependsOn": ["prepack"], + "cache": false, + "outputs": ["coverage/**"] + }, + "prepack": { + "dependsOn": ["^prepack"], + "outputs": ["dist/**"] + }, + "dev": { + "dependsOn": ["prepack"], + "cache": false + }, + "type:check": { + "dependsOn": ["prepack"] + } }, "globalEnv": ["NODE_ENV"] }