Skip to content
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

Subscription mode #1263

Merged
merged 31 commits into from
Feb 24, 2023
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
cbf8752
update subscribe with middleware
huozhi Jul 2, 2021
af89adb
fix lint & test
huozhi Jul 19, 2021
7283270
update typing, fix test and lint
huozhi Feb 1, 2022
bfa9056
refactor callback, rename, use refs for callbacks
huozhi Feb 3, 2022
510524a
Delay unmount
huozhi Feb 3, 2022
abfa159
Merge branch 'main' into subscribe
huozhi Feb 3, 2022
8ed9d72
Merge branch 'main' into subscribe
huozhi Apr 5, 2022
8f48cce
Merge branch 'main' into subscribe
huozhi Apr 12, 2022
8cfc3f5
no swr destruction and add getters, revert pkg.json
huozhi Apr 16, 2022
c023e89
callback -> next
huozhi Apr 17, 2022
2f33952
rename fix types and update test
huozhi Apr 17, 2022
138ede4
change exports to unstable_subscription
huozhi Apr 19, 2022
350f022
Merge branch 'main' into subscribe
huozhi May 17, 2022
7ad1935
manage subs with useESE
huozhi May 17, 2022
beaf868
use serialize
huozhi May 18, 2022
0e873eb
merge canary
huozhi Sep 22, 2022
81e2ab1
Merge branch 'main' into subscribe
huozhi Jan 23, 2023
4753152
fix lint
huozhi Jan 23, 2023
401738c
use cache helper to update error
huozhi Jan 24, 2023
6aed4ea
update exports path
huozhi Jan 26, 2023
ca2668b
fix script
huozhi Feb 1, 2023
c0ab66e
Merge branch 'main' into subscribe
huozhi Feb 1, 2023
8276703
pub check
huozhi Feb 1, 2023
c21aa98
add exp jsdoc
huozhi Feb 24, 2023
1c2b974
use cache-scoped storage
shuding Feb 24, 2023
5656fef
use prefixed key; add more tests
shuding Feb 24, 2023
12e3111
fix type
shuding Feb 24, 2023
0c04bfd
remove subscriber ref
shuding Feb 24, 2023
73ffb1c
rename
shuding Feb 24, 2023
7963ae8
fix lint
huozhi Feb 24, 2023
54dfaee
Merge branch 'main' into subscribe
huozhi Feb 24, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 2 additions & 2 deletions _internal/utils/mutate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,8 @@ export async function internalMutate<Data>(
for (let keyIt = it.next(); !keyIt.done; keyIt = it.next()) {
const key = keyIt.value
if (
// Skip the special useSWRInfinite keys.
!key.startsWith('$inf$') &&
// Skip the special useSWRInfinite and useSWRSubscription keys.
!/^\$(inf|sub)\$/.test(key) &&
keyFilter((cache.get(key) as { _k: Arguments })._k)
) {
matchedKeys.push(key)
Expand Down
1 change: 1 addition & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ module.exports = {
'^swr$': '<rootDir>/core/index.ts',
'^swr/infinite$': '<rootDir>/infinite/index.ts',
'^swr/immutable$': '<rootDir>/immutable/index.ts',
'^swr/subscription$': '<rootDir>/subscription/index.ts',
'^swr/mutation$': '<rootDir>/mutation/index.ts',
'^swr/_internal$': '<rootDir>/_internal/index.ts'
},
Expand Down
12 changes: 10 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@
"module": "./immutable/dist/index.esm.js",
"require": "./immutable/dist/index.js"
},
"./subscription": {
"types": "./subscription/dist/index.d.ts",
"import": "./subscription/dist/index.mjs",
"module": "./subscription/dist/index.esm.js",
"require": "./subscription/dist/index.js"
},
"./mutation": {
"types": "./mutation/dist/mutation/index.d.ts",
"import": "./mutation/dist/index.mjs",
Expand All @@ -53,11 +59,13 @@
"immutable/dist/**/*.{js,d.ts,mjs}",
"mutation/dist/**/*.{js,d.ts,mjs}",
"_internal/dist/**/*.{js,d.ts,mjs}",
"subscription/dist/*.{js,d.ts,mjs}",
"core/package.json",
"infinite/package.json",
"immutable/package.json",
"mutation/package.json",
"_internal/package.json"
"_internal/package.json",
"subscription/package.json"
],
"repository": "github:vercel/swr",
"homepage": "https://swr.vercel.app",
Expand All @@ -68,7 +76,7 @@
"csb:build": "pnpm build",
"clean": "pnpm -r run clean && rimraf playwright-report test-result",
"watch": "pnpm -r run watch",
"build": "pnpm build-package _internal && pnpm build-package core && pnpm build-package infinite && pnpm build-package immutable && pnpm build-package mutation",
"build": "pnpm build-package _internal && pnpm build-package core && pnpm build-package infinite && pnpm build-package immutable && pnpm build-package mutation && pnpm build-package subscription",
"build:e2e": "pnpm next build e2e/site",
"build-package": "bunchee index.ts --cwd",
"types:check": "pnpm -r run types:check",
Expand Down
132 changes: 132 additions & 0 deletions subscription/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import type { Key, SWRHook, Middleware, SWRConfiguration, SWRConfig } from 'swr'

import useSWR from 'swr'
import {
withMiddleware,
serialize,
useIsomorphicLayoutEffect,
createCacheHelper
} from 'swr/_internal'

export type SWRSubscription<Data = any, Error = any> = (
key: Key,
{ next }: { next: (err?: Error | null, data?: Data) => void }
) => () => void

export type SWRSubscriptionResponse<Data = any, Error = any> = {
data?: Data
error?: Error
}

export type SWRSubscriptionHook<Data = any, Error = any> = (
key: Key,
subscribe: SWRSubscription<Data, Error>,
config?: SWRConfiguration
) => SWRSubscriptionResponse<Data, Error>

// [subscription count, disposer]
type SubscriptionStates = [Map<string, number>, Map<string, () => void>]
const subscriptionStorage = new WeakMap<object, SubscriptionStates>()

const SUBSCRIPTION_PREFIX = '$sub$'

export const subscription = (<Data, Error>(useSWRNext: SWRHook) =>
(
_key: Key,
subscribe: SWRSubscription<Data, Error>,
config: SWRConfiguration & typeof SWRConfig.defaultValue
): SWRSubscriptionResponse<Data, Error> => {
const [key] = serialize(_key)

// Prefix the key to avoid conflicts with other SWR resources.
const subscriptionKey = key ? SUBSCRIPTION_PREFIX + key : undefined
const swr = useSWRNext(subscriptionKey, null, config)

const { cache } = config

// Ensure that the subscription state is scoped by the cache boundary, so
// you can have multiple SWR zones with subscriptions having the same key.
if (!subscriptionStorage.has(cache)) {
subscriptionStorage.set(cache, [
new Map<string, number>(),
new Map<string, () => void>()
])
}
const [subscriptions, disposers] = subscriptionStorage.get(cache)!

huozhi marked this conversation as resolved.
Show resolved Hide resolved
useIsomorphicLayoutEffect(() => {
if (!subscriptionKey) return

const [, set] = createCacheHelper<Data>(cache, subscriptionKey)
const refCount = subscriptions.get(subscriptionKey) || 0

const next = (error?: Error | null, data?: Data) => {
if (error !== null && typeof error !== 'undefined') {
set({ error })
} else {
swr.mutate(data, false)
}
}

// Increment the ref count.
subscriptions.set(subscriptionKey, refCount + 1)

if (!refCount) {
const dispose = subscribe(key, { next })
if (typeof dispose !== 'function') {
throw new Error(
'The `subscribe` function must return a function to unsubscribe.'
)
}
disposers.set(subscriptionKey, dispose)
}

return () => {
// Prevent frequent unsubscribe caused by unmount
setTimeout(() => {
// TODO: Throw error during development if count is undefined.
const count = subscriptions.get(subscriptionKey)! - 1

subscriptions.set(subscriptionKey, count)

// Dispose if it's the last one.
if (!count) {
disposers.get(subscriptionKey)!()
}
})
}

// eslint-disable-next-line react-hooks/exhaustive-deps
}, [subscriptionKey])

return {
get data() {
return swr.data
},
get error() {
return swr.error
}
}
}) as unknown as Middleware

/**
* A hook to subscribe a SWR resource to an external data source for continuous updates.
* @experimental This API is experimental and might change in the future.
* @example
* ```jsx
* import useSWRSubscription from 'swr/subscription'
*
* const { data, error } = useSWRSubscription(key, (key, { next }) => {
* const unsubscribe = dataSource.subscribe(key, (err, data) => {
* next(err, data)
* })
* return unsubscribe
* })
* ```
*/
const useSWRSubscription = withMiddleware(
useSWR,
subscription
) as SWRSubscriptionHook

export default useSWRSubscription
17 changes: 17 additions & 0 deletions subscription/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"main": "./dist/index.js",
"module": "./dist/index.esm.js",
"types": "./dist/index.d.ts",
"exports": "./dist/index.mjs",
"private": true,
"scripts": {
"watch": "bunchee index.ts -w",
"build": "bunchee index.ts",
"types:check": "tsc --noEmit",
"clean": "rimraf dist"
},
"peerDependencies": {
"swr": "*",
"react": "*"
}
}
9 changes: 9 additions & 0 deletions subscription/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "..",
},
"include": [".", "../src"],
"exclude": ["./dist"]
}
139 changes: 139 additions & 0 deletions test/use-swr-subscription.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import React from 'react'
import { act, screen } from '@testing-library/react'
import { sleep, renderWithConfig, createKey } from './utils'

import useSWRSubscription from 'swr/subscription'
import useSWR from 'swr'

describe('useSWRSubscription', () => {
it('should update the state', async () => {
const swrKey = createKey()

let intervalId
let res = 0
function subscribe(key, { next }) {
intervalId = setInterval(() => {
if (res === 3) {
const err = new Error(key + 'error')
next(err)
} else {
next(undefined, key + res)
}
res++
}, 100)

return () => {}
}

function Page() {
const { data, error } = useSWRSubscription(swrKey, subscribe, {
fallbackData: 'fallback'
})
return <div>{error ? error.message : data}</div>
}

renderWithConfig(<Page />)
await act(() => sleep(10))
screen.getByText(`fallback`)
await act(() => sleep(100))
screen.getByText(`${swrKey}0`)
await act(() => sleep(100))
screen.getByText(`${swrKey}1`)
await act(() => sleep(100))
screen.getByText(`${swrKey}2`)
await act(() => sleep(100))
screen.getByText(`${swrKey}error`)
clearInterval(intervalId)
await sleep(100)
screen.getByText(`${swrKey}error`)
})

it('should deduplicate subscriptions', async () => {
const swrKey = createKey()

let subscriptionCount = 0

function subscribe(key, { next }) {
++subscriptionCount
let res = 0
const intervalId = setInterval(() => {
if (res === 3) {
const err = new Error(key + 'error')
next(err)
} else {
next(undefined, key + res)
}
res++
}, 100)

return () => {
clearInterval(intervalId)
}
}

function Page() {
const { data, error } = useSWRSubscription(swrKey, subscribe, {
fallbackData: 'fallback'
})
useSWRSubscription(swrKey, subscribe)
useSWRSubscription(swrKey, subscribe)

return <div>{error ? error.message : data}</div>
}

renderWithConfig(<Page />)
await act(() => sleep(10))
screen.getByText(`fallback`)
await act(() => sleep(100))
screen.getByText(`${swrKey}0`)
await act(() => sleep(100))
screen.getByText(`${swrKey}1`)
await act(() => sleep(100))
screen.getByText(`${swrKey}2`)

expect(subscriptionCount).toBe(1)
})

it('should not conflict with useSWR state', async () => {
const swrKey = createKey()

function subscribe(key, { next }) {
let res = 0
const intervalId = setInterval(() => {
if (res === 3) {
const err = new Error(key + 'error')
next(err)
} else {
next(undefined, key + res)
}
res++
}, 100)

return () => {
clearInterval(intervalId)
}
}

function Page() {
const { data, error } = useSWRSubscription(swrKey, subscribe, {
fallbackData: 'fallback'
})
const { data: swrData } = useSWR(swrKey, () => 'swr')
return (
<div>
{swrData}:{error ? error.message : data}
</div>
)
}

renderWithConfig(<Page />)
await act(() => sleep(10))
screen.getByText(`swr:fallback`)
await act(() => sleep(100))
screen.getByText(`swr:${swrKey}0`)
await act(() => sleep(100))
screen.getByText(`swr:${swrKey}1`)
await act(() => sleep(100))
screen.getByText(`swr:${swrKey}2`)
})
})
3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
"swr/infinite": ["./infinite/index.ts"],
"swr/immutable": ["./immutable/index.ts"],
"swr/mutation": ["./mutation/index.ts"],
"swr/_internal": ["./_internal/index.ts"]
"swr/_internal": ["./_internal/index.ts"],
"swr/subscription": ["subscription/index.ts"],
},
"incremental": true
},
Expand Down