@syncular/react 0.15.4 → 0.15.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -67,8 +67,9 @@ A ready client can be passed directly:
67
67
  ```
68
68
 
69
69
  For async engines, create one resource outside React render. It owns one
70
- initialization attempt across StrictMode remounts and closes the client exactly
71
- once when explicitly disposed:
70
+ initialization attempt across StrictMode remounts, can retry a failed startup
71
+ without replacing the provider, and closes the client exactly once when
72
+ explicitly disposed:
72
73
 
73
74
  ```tsx
74
75
  import { createSyncClientResource, SyncProvider } from '@syncular/react';
@@ -78,7 +79,12 @@ const clientResource = createSyncClientResource(() => createClient());
78
79
  <SyncProvider
79
80
  client={clientResource}
80
81
  fallback={<p>Starting local database…</p>}
81
- renderError={(error) => <p>{error.message}</p>}
82
+ renderError={(error, retry) => (
83
+ <div>
84
+ <p>{error.message}</p>
85
+ <button onClick={() => void retry()}>Try again</button>
86
+ </div>
87
+ )}
82
88
  >
83
89
  <App />
84
90
  </SyncProvider>
@@ -87,6 +93,12 @@ const clientResource = createSyncClientResource(() => createClient());
87
93
  Call `await clientResource.dispose()` from the application's real lifecycle
88
94
  owner, not from a StrictMode-sensitive child effect.
89
95
 
96
+ The resource is stable across React remounts, not JavaScript module replacement.
97
+ During development, preserve it in the bundler's hot-module data or dispose the
98
+ old resource before creating another one. Otherwise the old worker and the new
99
+ worker can briefly compete for the same persistent OPFS directory. Never wipe
100
+ or rename the database in response to a retryable startup error.
101
+
90
102
  ## `useMutation`
91
103
 
92
104
  `useMutation()` retains the raw batch API. `useMutation(generatedTable)` adds
@@ -16,6 +16,6 @@ export interface SyncProviderProps {
16
16
  readonly client: SyncClientLike | SyncClientResource;
17
17
  readonly children?: ReactNode;
18
18
  readonly fallback?: ReactNode;
19
- readonly renderError?: (error: Error) => ReactNode;
19
+ readonly renderError?: (error: Error, retry: () => Promise<void>) => ReactNode;
20
20
  }
21
21
  export declare function SyncProvider(props: SyncProviderProps): ReactNode;
package/dist/provider.js CHANGED
@@ -56,8 +56,8 @@ export function SyncProvider(props) {
56
56
  if (snapshot.phase === 'pending')
57
57
  return props.fallback ?? null;
58
58
  if (snapshot.phase === 'error') {
59
- if (props.renderError !== undefined)
60
- return props.renderError(snapshot.error);
59
+ if (props.renderError !== undefined && resource !== undefined)
60
+ return props.renderError(snapshot.error, resource.retry);
61
61
  throw snapshot.error;
62
62
  }
63
63
  return createElement(ReadySyncProvider, { client: snapshot.client }, props.children);
@@ -12,6 +12,8 @@ export interface SyncClientResource {
12
12
  readonly kind: 'syncular-client-resource';
13
13
  subscribe(listener: () => void): () => void;
14
14
  getSnapshot(): SyncClientResourceSnapshot;
15
+ /** Re-run a failed initialization attempt. Pending/ready calls are no-ops. */
16
+ retry(): Promise<void>;
15
17
  dispose(): Promise<void>;
16
18
  }
17
19
  export declare function createSyncClientResource(factory: () => SyncClientLike | Promise<SyncClientLike>): SyncClientResource;
package/dist/resource.js CHANGED
@@ -3,31 +3,43 @@ export function createSyncClientResource(factory) {
3
3
  let snapshot = { phase: 'pending' };
4
4
  let disposed = false;
5
5
  let closed = false;
6
- const initialized = Promise.resolve()
7
- .then(factory)
8
- .then(async (client) => {
9
- if (disposed) {
10
- const close = client
11
- .close;
12
- if (close !== undefined && !closed) {
13
- closed = true;
14
- await close.call(client);
15
- }
16
- return;
17
- }
18
- snapshot = { phase: 'ready', client };
19
- for (const listener of listeners)
20
- listener();
21
- }, (error) => {
22
- if (disposed)
23
- return;
24
- snapshot = {
25
- phase: 'error',
26
- error: error instanceof Error ? error : new Error(String(error)),
27
- };
6
+ let initialized;
7
+ const notify = () => {
28
8
  for (const listener of listeners)
29
9
  listener();
30
- });
10
+ };
11
+ const closeClient = async (client) => {
12
+ const close = client.close;
13
+ if (close !== undefined && !closed) {
14
+ closed = true;
15
+ await close.call(client);
16
+ }
17
+ };
18
+ const initialize = (publishPending) => {
19
+ if (publishPending) {
20
+ snapshot = { phase: 'pending' };
21
+ notify();
22
+ }
23
+ return Promise.resolve()
24
+ .then(factory)
25
+ .then(async (client) => {
26
+ if (disposed) {
27
+ await closeClient(client);
28
+ return;
29
+ }
30
+ snapshot = { phase: 'ready', client };
31
+ notify();
32
+ }, (error) => {
33
+ if (disposed)
34
+ return;
35
+ snapshot = {
36
+ phase: 'error',
37
+ error: error instanceof Error ? error : new Error(String(error)),
38
+ };
39
+ notify();
40
+ });
41
+ };
42
+ initialized = initialize(false);
31
43
  return {
32
44
  kind: 'syncular-client-resource',
33
45
  subscribe(listener) {
@@ -35,17 +47,19 @@ export function createSyncClientResource(factory) {
35
47
  return () => listeners.delete(listener);
36
48
  },
37
49
  getSnapshot: () => snapshot,
50
+ retry() {
51
+ if (disposed || snapshot.phase !== 'error')
52
+ return initialized;
53
+ initialized = initialize(true);
54
+ return initialized;
55
+ },
38
56
  async dispose() {
39
57
  if (disposed)
40
58
  return;
41
59
  disposed = true;
42
60
  await initialized;
43
61
  if (snapshot.phase === 'ready' && !closed) {
44
- const close = snapshot.client.close;
45
- if (close !== undefined) {
46
- closed = true;
47
- await close.call(snapshot.client);
48
- }
62
+ await closeClient(snapshot.client);
49
63
  }
50
64
  listeners.clear();
51
65
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/react",
3
- "version": "0.15.4",
3
+ "version": "0.15.6",
4
4
  "description": "React hooks for Syncular offline-first sync",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -48,15 +48,15 @@
48
48
  "test": "bun test --preload ./test/setup.ts"
49
49
  },
50
50
  "dependencies": {
51
- "@syncular/client": "0.15.4"
51
+ "@syncular/client": "0.15.6"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "react": ">=18.0.0"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@happy-dom/global-registrator": "^15.11.0",
58
- "@syncular/core": "0.15.4",
59
- "@syncular/server": "0.15.4",
58
+ "@syncular/core": "0.15.6",
59
+ "@syncular/server": "0.15.6",
60
60
  "@testing-library/react": "^16.1.0",
61
61
  "@types/react": "^18.3.0",
62
62
  "react": "^18.3.1",
package/src/provider.ts CHANGED
@@ -33,7 +33,10 @@ export interface SyncProviderProps {
33
33
  readonly client: SyncClientLike | SyncClientResource;
34
34
  readonly children?: ReactNode;
35
35
  readonly fallback?: ReactNode;
36
- readonly renderError?: (error: Error) => ReactNode;
36
+ readonly renderError?: (
37
+ error: Error,
38
+ retry: () => Promise<void>,
39
+ ) => ReactNode;
37
40
  }
38
41
 
39
42
  interface ClientRecord {
@@ -106,8 +109,8 @@ export function SyncProvider(props: SyncProviderProps): ReactNode {
106
109
  );
107
110
  if (snapshot.phase === 'pending') return props.fallback ?? null;
108
111
  if (snapshot.phase === 'error') {
109
- if (props.renderError !== undefined)
110
- return props.renderError(snapshot.error);
112
+ if (props.renderError !== undefined && resource !== undefined)
113
+ return props.renderError(snapshot.error, resource.retry);
111
114
  throw snapshot.error;
112
115
  }
113
116
  return createElement(
package/src/resource.ts CHANGED
@@ -9,6 +9,8 @@ export interface SyncClientResource {
9
9
  readonly kind: 'syncular-client-resource';
10
10
  subscribe(listener: () => void): () => void;
11
11
  getSnapshot(): SyncClientResourceSnapshot;
12
+ /** Re-run a failed initialization attempt. Pending/ready calls are no-ops. */
13
+ retry(): Promise<void>;
12
14
  dispose(): Promise<void>;
13
15
  }
14
16
 
@@ -19,31 +21,48 @@ export function createSyncClientResource(
19
21
  let snapshot: SyncClientResourceSnapshot = { phase: 'pending' };
20
22
  let disposed = false;
21
23
  let closed = false;
22
- const initialized = Promise.resolve()
23
- .then(factory)
24
- .then(
25
- async (client) => {
26
- if (disposed) {
27
- const close = (client as { close?: () => void | Promise<void> })
28
- .close;
29
- if (close !== undefined && !closed) {
30
- closed = true;
31
- await close.call(client);
24
+ let initialized: Promise<void>;
25
+
26
+ const notify = (): void => {
27
+ for (const listener of listeners) listener();
28
+ };
29
+
30
+ const closeClient = async (client: SyncClientLike): Promise<void> => {
31
+ const close = (client as { close?: () => void | Promise<void> }).close;
32
+ if (close !== undefined && !closed) {
33
+ closed = true;
34
+ await close.call(client);
35
+ }
36
+ };
37
+
38
+ const initialize = (publishPending: boolean): Promise<void> => {
39
+ if (publishPending) {
40
+ snapshot = { phase: 'pending' };
41
+ notify();
42
+ }
43
+ return Promise.resolve()
44
+ .then(factory)
45
+ .then(
46
+ async (client) => {
47
+ if (disposed) {
48
+ await closeClient(client);
49
+ return;
32
50
  }
33
- return;
34
- }
35
- snapshot = { phase: 'ready', client };
36
- for (const listener of listeners) listener();
37
- },
38
- (error: unknown) => {
39
- if (disposed) return;
40
- snapshot = {
41
- phase: 'error',
42
- error: error instanceof Error ? error : new Error(String(error)),
43
- };
44
- for (const listener of listeners) listener();
45
- },
46
- );
51
+ snapshot = { phase: 'ready', client };
52
+ notify();
53
+ },
54
+ (error: unknown) => {
55
+ if (disposed) return;
56
+ snapshot = {
57
+ phase: 'error',
58
+ error: error instanceof Error ? error : new Error(String(error)),
59
+ };
60
+ notify();
61
+ },
62
+ );
63
+ };
64
+
65
+ initialized = initialize(false);
47
66
 
48
67
  return {
49
68
  kind: 'syncular-client-resource',
@@ -52,18 +71,17 @@ export function createSyncClientResource(
52
71
  return () => listeners.delete(listener);
53
72
  },
54
73
  getSnapshot: () => snapshot,
74
+ retry() {
75
+ if (disposed || snapshot.phase !== 'error') return initialized;
76
+ initialized = initialize(true);
77
+ return initialized;
78
+ },
55
79
  async dispose() {
56
80
  if (disposed) return;
57
81
  disposed = true;
58
82
  await initialized;
59
83
  if (snapshot.phase === 'ready' && !closed) {
60
- const close = (
61
- snapshot.client as { close?: () => void | Promise<void> }
62
- ).close;
63
- if (close !== undefined) {
64
- closed = true;
65
- await close.call(snapshot.client);
66
- }
84
+ await closeClient(snapshot.client);
67
85
  }
68
86
  listeners.clear();
69
87
  },