Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/blue-cameras-flash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@journeyapps/powersync-react": minor
---

Add `usePowerSyncStatus` hook
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,12 @@ _Bad connectivity is everywhere, and we're tired of it. PowerSync is on a missio
Demo applications are located in the [`demos/`](./demos/) directory. Also see our [Demo Apps / Example Projects](https://docs.powersync.com/resources/demo-apps-example-projects) gallery which lists all projects by the backend and client-side framework they use.

### React Native

- [demos/react-native-supabase-todolist](./demos/react-native-supabase-todolist): A React Native to-do list example app using a Supabase backend.
- [demos/django-react-native-todolist](./demos/django-react-native-todolist) A React Native to-do list example app using a Django backend.

### Web

- [demos/react-supabase-todolist](./demos/react-supabase-todolist/README.md): A React to-do list example app using the PowerSync Web SDK and a Supabase backend.
- [demos/yjs-react-supabase-text-collab](./demos/yjs-react-supabase-text-collab/README.md): A React real-time text editing collaboration example app powered by [Yjs](https://github.com/yjs/yjs) CRDTs and [Tiptap](https://tiptap.dev/), using the PowerSync Web SDK and a Supabase backend.
- [demos/vue-supabase-todolist](./demos/vue-supabase-todolist/README.md): A Vue to-do list example app using the PowerSync Web SDK and a Supabase backend.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Alert, Text } from 'react-native';
import { Icon } from 'react-native-elements';
import { useNavigation } from 'expo-router';
import { useSystem } from '../stores/system';
import { usePowerSyncStatus } from '@journeyapps/powersync-react';
import { Header } from 'react-native-elements';
import { observer } from 'mobx-react-lite';
import { DrawerActions } from '@react-navigation/native';
Expand All @@ -12,7 +13,9 @@ export const HeaderWidget: React.FC<{
}> = observer((props) => {
const { title } = props;
const { powersync } = useSystem();
const status = usePowerSyncStatus();
const navigation = useNavigation();

return (
<Header
leftComponent={
Expand All @@ -28,16 +31,15 @@ export const HeaderWidget: React.FC<{
}
rightComponent={
<Icon
name={powersync.connected ? 'wifi' : 'wifi-off'}
name={status.connected ? 'wifi' : 'wifi-off'}
type="material-community"
color="black"
size={20}
style={{ padding: 5 }}
onPress={() => {
Alert.alert(
'Status',
`${powersync.connected ? 'Connected' : 'Disconnected'}. \nLast Synced at ${
powersync.currentStatus?.lastSyncedAt.toISOString() ?? '-'
`${status.connected ? 'Connected' : 'Disconnected'}. \nLast Synced at ${status.lastSyncedAt?.toISOString() ?? '-'
}\nVersion: ${powersync.sdkVersion}`
);
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Alert, Text } from 'react-native';
import { Icon } from 'react-native-elements';
import { useNavigation } from 'expo-router';
import { Header } from 'react-native-elements';
import { usePowerSyncStatus } from '@journeyapps/powersync-react';
import { DrawerActions } from '@react-navigation/native';
import { useSystem } from '../powersync/system';

Expand All @@ -12,15 +13,7 @@ export const HeaderWidget: React.FC<{
const system = useSystem();
const { powersync } = system;
const navigation = useNavigation();
const [connected, setConnected] = React.useState(powersync.connected);

React.useEffect(() => {
return powersync.registerListener({
statusChanged: (status) => {
setConnected(status.connected);
}
});
}, [powersync]);
const status = usePowerSyncStatus();

const { title } = props;
return (
Expand All @@ -38,7 +31,7 @@ export const HeaderWidget: React.FC<{
}
rightComponent={
<Icon
name={connected ? 'wifi' : 'wifi-off'}
name={status.connected ? 'wifi' : 'wifi-off'}
type="material-community"
color="black"
size={20}
Expand All @@ -47,8 +40,7 @@ export const HeaderWidget: React.FC<{
system.attachmentQueue.trigger();
Alert.alert(
'Status',
`${connected ? 'Connected' : 'Disconnected'}. \nLast Synced at ${
powersync.currentStatus?.lastSyncedAt.toISOString() ?? '-'
`${status.connected ? 'Connected' : 'Disconnected'}. \nLast Synced at ${status?.lastSyncedAt?.toISOString() ?? '-'
}\nVersion: ${powersync.sdkVersion}`
);
}}
Expand Down
22 changes: 5 additions & 17 deletions demos/react-supabase-todolist/src/app/views/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,16 @@ import React from 'react';

import { useNavigationPanel } from '@/components/navigation/NavigationPanelContext';
import { useSupabase } from '@/components/providers/SystemProvider';
import { usePowerSync } from '@journeyapps/powersync-react';
import { usePowerSync, usePowerSyncStatus } from '@journeyapps/powersync-react';
import { useNavigate } from 'react-router-dom';
import { LOGIN_ROUTE, SQL_CONSOLE_ROUTE, TODO_LISTS_ROUTE } from '@/app/router';

export default function ViewsLayout({ children }: { children: React.ReactNode }) {
const powerSync = usePowerSync();
const status = usePowerSyncStatus();
const supabase = useSupabase();
const navigate = useNavigate();

const [syncStatus, setSyncStatus] = React.useState(powerSync.currentStatus);
const [openDrawer, setOpenDrawer] = React.useState(false);
const { title } = useNavigationPanel();

Expand Down Expand Up @@ -63,15 +63,6 @@ export default function ViewsLayout({ children }: { children: React.ReactNode })
[powerSync, supabase]
);

React.useEffect(() => {
const l = powerSync.registerListener({
statusChanged: (status) => {
setSyncStatus(status);
}
});
return () => l?.();
}, [powerSync]);

return (
<S.MainBox>
<S.TopBar position="static">
Expand All @@ -89,12 +80,9 @@ export default function ViewsLayout({ children }: { children: React.ReactNode })
<Box sx={{ flexGrow: 1 }}>
<Typography>{title}</Typography>
</Box>
<NorthIcon
sx={{ marginRight: '-10px' }}
color={syncStatus?.dataFlowStatus.uploading ? 'primary' : 'inherit'}
/>
<SouthIcon color={syncStatus?.dataFlowStatus.downloading ? 'primary' : 'inherit'} />
{syncStatus?.connected ? <WifiIcon /> : <SignalWifiOffIcon />}
<NorthIcon sx={{ marginRight: '-10px' }} color={status?.dataFlowStatus.uploading ? 'primary' : 'inherit'} />
<SouthIcon color={status?.dataFlowStatus.downloading ? 'primary' : 'inherit'} />
{status?.connected ? <WifiIcon /> : <SignalWifiOffIcon />}
</Toolbar>
</S.TopBar>
<Drawer anchor={'left'} open={openDrawer} onClose={() => setOpenDrawer(false)}>
Expand Down
17 changes: 17 additions & 0 deletions packages/powersync-react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Configure a PowerSync DB connection and add it to a context provider.
// App.jsx
import { PowerSyncDatabase } from '@journeyapps/powersync-react-native';
import { PowerSyncContext } from "@journeyapps/powersync-react";

export const App = () => {
const powerSync = React.useMemo(() => {
// Setup PowerSync client
Expand Down Expand Up @@ -42,6 +43,22 @@ export const TodoListDisplay = () => {
}
```

### Accessing PowerSync Status

The provided PowerSync client status is available with the `usePowerSyncStatus` hook.

```JSX
import { usePowerSyncStatus } from "@journeyapps/powersync-react";

const Component = () => {
const status = usePowerSyncStatus();

return <div>
status.connected ? 'wifi' : 'wifi-off'
</div>
};
```

### Watched Queries

Watched queries will automatically update when a dependant table is updated.
Expand Down
4 changes: 4 additions & 0 deletions packages/powersync-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"scripts": {
"build": "tsc -b",
"clean": "rm -rf lib tsconfig.tsbuildinfo",
"test": "vitest",
"watch": "tsc -b -w"
},
"repository": {
Expand All @@ -33,7 +34,10 @@
"react": "*"
},
"devDependencies": {
"@testing-library/react": "^15.0.2",
"@testing-library/react-hooks": "^8.0.1",
"@types/react": "^18.2.34",
"jsdom": "^24.0.0",
"react": "18.2.0",
"typescript": "^5.1.3"
}
Expand Down
31 changes: 31 additions & 0 deletions packages/powersync-react/src/hooks/usePowerSyncStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { useContext, useEffect, useState } from 'react';
import { PowerSyncContext } from './PowerSyncContext';

/**
* Custom hook that provides access to the current status of PowerSync.
* @returns The PowerSync Database status.
* @example
* const Component = () => {
* const status = usePowerSyncStatus();
*
* return <div>
* status.connected ? 'wifi' : 'wifi-off'
* </div>
* };
*/
export const usePowerSyncStatus = () => {
const powerSync = useContext(PowerSyncContext);
const [syncStatus, setSyncStatus] = useState(powerSync.currentStatus);

useEffect(() => {
const listener = powerSync.registerListener({
statusChanged: (status) => {
setSyncStatus(status);
}
});

return () => listener?.();
}, [powerSync]);

return syncStatus;
};
5 changes: 3 additions & 2 deletions packages/powersync-react/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './hooks/PowerSyncContext';
export * from './hooks/usePowerSyncQuery';
export * from './hooks/usePowerSyncWatchedQuery';
export { usePowerSyncQuery } from './hooks/usePowerSyncQuery';
export { usePowerSyncWatchedQuery } from './hooks/usePowerSyncWatchedQuery';
export { usePowerSyncStatus } from './hooks/usePowerSyncStatus';
24 changes: 24 additions & 0 deletions packages/powersync-react/tests/PowerSyncContext.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import React from 'react';
import { render } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import * as SUT from '../src/hooks/PowerSyncContext';

describe('PowerSyncContext', () => {
describe('usePowerSync', () => {
it('should retrieve the PowerSync DB from the context and display the test text', async () => {
const TestComponent = () => {
const powerSyncDb = SUT.usePowerSync() as any;
return <div>{powerSyncDb.testText}</div>;
};

const { findByText } = render(
<SUT.PowerSyncContext.Provider value={{ testText: 'Test Text' } as any}>
<TestComponent />
</SUT.PowerSyncContext.Provider>
);
const hello = await findByText('Test Text');

expect(hello).toBeDefined;
});
});
});
22 changes: 22 additions & 0 deletions packages/powersync-react/tests/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"baseUrl": "./",
"esModuleInterop": true,
"jsx": "react",
"rootDir": "../",
"composite": true,
"outDir": "./lib",
"lib": ["esnext", "DOM"],
"module": "esnext",
"sourceMap": true,
"moduleResolution": "node",
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"noImplicitUseStrict": false,
"noStrictGenericChecks": false,
"resolveJsonModule": true,
"skipLibCheck": true,
"target": "esnext"
},
"include": ["../src/**/*"]
}
59 changes: 59 additions & 0 deletions packages/powersync-react/tests/usePowerSyncStatus.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import React from 'react';
import { act, renderHook } from '@testing-library/react-hooks';
import { vi, describe, expect, it, afterEach } from 'vitest';
import { usePowerSyncStatus } from '../src/hooks/usePowerSyncStatus';
import { PowerSyncContext } from '../src/hooks/PowerSyncContext';

const mockPowerSync = {
currentStatus: { status: 'initial' },
registerListener: vi.fn(() => ({
statusChanged: vi.fn(() => 'updated')
}))
};

vi.mock('./PowerSyncContext', () => ({
useContext: vi.fn(() => mockPowerSync)
}));

describe('usePowerSyncStatus', () => {
afterEach(() => {
vi.resetAllMocks();
});

it('should initialize with the current status', () => {
const wrapper = ({ children }) => (
<PowerSyncContext.Provider value={mockPowerSync as any}>{children}</PowerSyncContext.Provider>
);

const { result } = renderHook(() => usePowerSyncStatus(), { wrapper });
expect(result.current).toEqual(mockPowerSync.currentStatus);
});

// TODO: Get this test to work
it.skip('should update the status when the listener is called', () => {
const wrapper = ({ children }) => (
<PowerSyncContext.Provider value={mockPowerSync as any}>{children}</PowerSyncContext.Provider>
);

const { result } = renderHook(() => usePowerSyncStatus(), { wrapper });

act(() => {
mockPowerSync.registerListener.mockResolvedValue({ statusChanged: vi.fn(() => 'updated') });
});

expect(result.current).toEqual({ status: 'updated' });
});

it('should run the listener on unmount', () => {
const wrapper = ({ children }) => (
<PowerSyncContext.Provider value={mockPowerSync as any}>{children}</PowerSyncContext.Provider>
);

const { unmount } = renderHook(() => usePowerSyncStatus(), { wrapper });
const listenerUnsubscribe = mockPowerSync.registerListener;

unmount();

expect(listenerUnsubscribe).toHaveBeenCalled();
});
});
3 changes: 2 additions & 1 deletion packages/powersync-react/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,6 @@
{
"path": "../powersync-sdk-common"
}
]
],
"include": ["src/**/*"]
}
9 changes: 9 additions & 0 deletions packages/powersync-react/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defineConfig, UserConfigExport } from 'vitest/config';

const config: UserConfigExport = {
test: {
environment: 'jsdom'
}
};

export default defineConfig(config);
21 changes: 1 addition & 20 deletions packages/powersync-sdk-web/tests/performance.test.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,7 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
AbstractPowerSyncDatabase,
Column,
ColumnType,
Schema,
Table,
TableV2,
column
} from '@journeyapps/powersync-sdk-common';
import { v4 as uuid } from 'uuid';
import { AbstractPowerSyncDatabase, Schema, TableV2, column } from '@journeyapps/powersync-sdk-common';
import { WASQLitePowerSyncDatabaseOpenFactory } from '@journeyapps/powersync-sdk-web';

function randomIntFromInterval(min: number, max: number) {
// min included and max excluded
return Math.random() * (max - min) + min;
}

type User = {
name: string;
email: string;
};

describe('Basic', () => {
const users = new TableV2({
name: column.text,
Expand Down
Loading