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 (
-
- )
- }
- 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"]
}