@pylonsync/react 0.22.47 → 0.22.58

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.
@@ -0,0 +1,21 @@
1
+ import type { ReactNode } from "react";
2
+ import type { AppUpdater } from "./appUpdate";
3
+ export interface AppUpdateBarProps {
4
+ className?: string;
5
+ /** Text when a new build is ready. */
6
+ message?: ReactNode;
7
+ /** The button label. */
8
+ actionLabel?: ReactNode;
9
+ /** The button label while pending writes are sent before the reload. */
10
+ savingLabel?: ReactNode;
11
+ /** Text on an iOS home-screen app whose name or icon changed. */
12
+ reinstallMessage?: ReactNode;
13
+ updater?: AppUpdater;
14
+ }
15
+ /**
16
+ * A minimal, unstyled notice for `useAppUpdate`. Renders nothing until a
17
+ * new build is ready or the home-screen app must be added again. Style it
18
+ * with `className` and the `data-pylon-app-update` attribute, or build your
19
+ * own notice with the hook.
20
+ */
21
+ export declare function AppUpdateBar({ className, message, actionLabel, savingLabel, reinstallMessage, updater, }: AppUpdateBarProps): import("react").JSX.Element | null;
@@ -0,0 +1,166 @@
1
+ /** The path the runtime serves the latest build's version at. */
2
+ export declare const VERSION_URL = "/_pylon/version";
3
+ /** How often a visible page checks for a new build. */
4
+ export declare const CHECK_INTERVAL_MS: number;
5
+ /** How long `apply()` waits for a new worker to install or take over
6
+ * before it reloads anyway. */
7
+ export declare const WORKER_WAIT_MS: number;
8
+ /** A reload that lands on the same build within this window does not
9
+ * reload again. */
10
+ export declare const RELOAD_GUARD_MS: number;
11
+ /** One build of the app. */
12
+ export interface AppBuild {
13
+ /** The service worker version: a hash of the build. */
14
+ version: string;
15
+ /** When the build was made (ISO 8601), or null when unknown. */
16
+ builtAt: string | null;
17
+ }
18
+ export interface AppUpdateState {
19
+ /** The build this page runs. Null during SSR and in an app without
20
+ * `buildManifest({ offline })`. */
21
+ version: string | null;
22
+ builtAt: string | null;
23
+ /** The latest build the server reported. Null until the first check
24
+ * succeeds. */
25
+ latestVersion: string | null;
26
+ latestBuiltAt: string | null;
27
+ /** A newer build exists. `apply()` loads it. */
28
+ updateReady: boolean;
29
+ /** The app is on the iOS home screen, and its name, icons, display mode
30
+ * or start URL changed since it was added. The user must remove the app
31
+ * and add it again to see the change. */
32
+ reinstallRecommended: boolean;
33
+ /** `apply()` is running. */
34
+ applying: boolean;
35
+ /** `apply()` is waiting for pending writes to reach the server. */
36
+ waitingForSync: boolean;
37
+ }
38
+ /** The parts of `ServiceWorker` this module uses. */
39
+ export interface WorkerLike {
40
+ state?: string;
41
+ postMessage(message: unknown, transfer?: unknown[]): void;
42
+ addEventListener?(type: "statechange", listener: () => void): void;
43
+ }
44
+ /** The parts of `ServiceWorkerRegistration` this module uses. */
45
+ export interface UpdateRegistrationLike {
46
+ waiting?: WorkerLike | null;
47
+ installing?: WorkerLike | null;
48
+ update?(): Promise<unknown>;
49
+ addEventListener?(type: "updatefound", listener: () => void): void;
50
+ removeEventListener?(type: "updatefound", listener: () => void): void;
51
+ }
52
+ /** The parts of `ServiceWorkerContainer` this module uses. */
53
+ export interface UpdateContainerLike {
54
+ controller?: unknown;
55
+ getRegistration?(scope?: string): Promise<UpdateRegistrationLike | undefined>;
56
+ addEventListener?(type: "controllerchange", listener: () => void): void;
57
+ removeEventListener?(type: "controllerchange", listener: () => void): void;
58
+ }
59
+ interface StorageLike {
60
+ getItem(key: string): string | null;
61
+ setItem(key: string, value: string): void;
62
+ }
63
+ /** The pending writes `apply()` waits for. */
64
+ export interface PendingWrites {
65
+ count(): number;
66
+ subscribe(listener: () => void): () => void;
67
+ }
68
+ /** Everything the updater touches outside itself. Tests pass their own;
69
+ * the browser defaults come from `browserEnv`. */
70
+ export interface AppUpdateEnv {
71
+ serviceWorker: UpdateContainerLike | null;
72
+ fetch(url: string, init: RequestInit): Promise<{
73
+ ok: boolean;
74
+ json(): Promise<unknown>;
75
+ }>;
76
+ document: {
77
+ visibilityState: string;
78
+ addEventListener(type: "visibilitychange", listener: () => void): void;
79
+ removeEventListener(type: "visibilitychange", listener: () => void): void;
80
+ } | null;
81
+ setInterval(fn: () => void, ms: number): unknown;
82
+ clearInterval(handle: unknown): void;
83
+ setTimeout(fn: () => void, ms: number): unknown;
84
+ clearTimeout(handle: unknown): void;
85
+ now(): number;
86
+ reload(): void;
87
+ sessionStorage: StorageLike | null;
88
+ localStorage: StorageLike | null;
89
+ /** The page runs as an iOS home-screen app (`navigator.standalone`). */
90
+ iosStandalone: boolean;
91
+ /** The sync engine's pending writes, or null when the app has none. */
92
+ pendingWrites(): PendingWrites | null;
93
+ /** Asks a waiting worker for its version. Null when it does not answer. */
94
+ askVersion(worker: WorkerLike): Promise<string | null>;
95
+ warn(message: string): void;
96
+ }
97
+ /** The state during SSR and before the client runtime configured a build. */
98
+ export declare const INERT_APP_UPDATE_STATE: AppUpdateState;
99
+ /** Checks for new builds and loads them. One per page; see `appUpdate`. */
100
+ export declare class AppUpdater {
101
+ private build;
102
+ private production;
103
+ private envFactory;
104
+ private env;
105
+ private listeners;
106
+ private snapshot;
107
+ private latest;
108
+ private waitingVersion;
109
+ private reinstall;
110
+ private applying;
111
+ private waitingForSync;
112
+ private registration;
113
+ private running;
114
+ private interval;
115
+ private checking;
116
+ private applyRun;
117
+ private reloading;
118
+ constructor(envFactory: () => AppUpdateEnv | null);
119
+ /** Set the build this page runs. `production` is false under `pylon
120
+ * dev`, which leaves the updater inert. */
121
+ configure(build: AppBuild | null, production: boolean): void;
122
+ getSnapshot: () => AppUpdateState;
123
+ subscribe: (listener: () => void) => (() => void);
124
+ /** Ask the server for the latest build now. */
125
+ check: () => Promise<void>;
126
+ /**
127
+ * Load the newest build: wait until the outbox has no pending writes,
128
+ * let the waiting service worker take over, and reload the page once.
129
+ * Does nothing under `pylon dev`, during SSR, or right after a reload
130
+ * that did not change the build (so a page that calls `apply()` on its
131
+ * own cannot reload in a loop).
132
+ */
133
+ apply: () => Promise<void>;
134
+ private activeEnv;
135
+ private start;
136
+ private stop;
137
+ private onVisibility;
138
+ private emit;
139
+ private runCheck;
140
+ /** The Pylon worker's registration, with an `updatefound` listener that
141
+ * records a new worker once it is installed. */
142
+ private watchRegistration;
143
+ private readWaiting;
144
+ private readInstall;
145
+ private runApply;
146
+ private reload;
147
+ private drainOutbox;
148
+ private waitForInstalled;
149
+ }
150
+ /** The browser environment, or null outside a browser. */
151
+ export declare function browserEnv(): AppUpdateEnv | null;
152
+ /** The page's updater. `useAppUpdate()` reads it. */
153
+ export declare const appUpdate: AppUpdater;
154
+ /**
155
+ * Set the build this page runs. The SSR client runtime calls it through
156
+ * `registerOfflineWorker({ build })`; apps do not call it.
157
+ */
158
+ export declare function configureAppUpdate(build: AppBuild | null, production: boolean): void;
159
+ /** An updater with its own environment, for tests and for apps that
160
+ * render outside the SSR client runtime. */
161
+ export declare function createAppUpdate(options: {
162
+ build: AppBuild | null;
163
+ production: boolean;
164
+ env: AppUpdateEnv | null;
165
+ }): AppUpdater;
166
+ export {};
package/dist/db.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { SyncEngine, type Row, type SyncEngineConfig } from "@pylonsync/sync";
2
+ import { type QueuedUploadHandle, type QueueUploadOptions } from "./offlineUploads";
2
3
  import { type QueryOptions, type UseQueryReturn, type UseQueryOneReturn, type UseReactiveQueryReturn, type UseMutationReturn, type UseInfiniteQueryReturn, type AggregateSpec, type UseAggregateReturn, type SearchSpec, type UseSearchReturn } from "./hooks";
3
4
  import { type UploadedFile, type UploadFileOptions } from "./index";
4
5
  /**
@@ -174,6 +175,9 @@ export declare const db: {
174
175
  streamFn(name: string, args?: Record<string, unknown>): AsyncGenerator<string, unknown, unknown>;
175
176
  /** Upload a file through /api/files/init, a PUT, and /api/files/confirm. */
176
177
  uploadFile(input: File | Blob | ArrayBuffer | Uint8Array, options?: UploadFileOptions): Promise<UploadedFile>;
178
+ /** Keep a file on the device and upload it when there is a network,
179
+ * then set it on its record. See `queueUpload`. */
180
+ queueUpload(file: Blob, options: QueueUploadOptions): Promise<QueuedUploadHandle>;
177
181
  /** @deprecated Use `uploadFile`; extra fields were never stored. */
178
182
  uploadFileMultipart(file: File | Blob, fields?: Record<string, string>): Promise<UploadedFile>;
179
183
  };
package/dist/index.d.ts CHANGED
@@ -14,7 +14,7 @@ export type { ImageResponseOptions, ImageResponseFont, } from "./image-response"
14
14
  export type { PageProps, PageAuth, ServerData, SsrResponse, SsrCookieOptions, Metadata, GenerateMetadata, Sitemap, SitemapEntry, Robots, RobotsRule, LlmsTxt, LlmsSection, LlmsLink, RouteSegmentConfig, ErrorBoundaryProps, NotFoundProps, FormFields, FormDb, FormRequest, RouteHandler, RawRouteHandler, RawResponse, } from "./ssr";
15
15
  export { useRouter, useSearchParams, usePathname, useParams, useRouteSeed, useRouteData, redirect, notFound, NotFoundError, } from "./useRouter";
16
16
  export type { PylonRouter } from "./useRouter";
17
- import { type Storage as PylonStorage, type TransportConfig } from "@pylonsync/sync";
17
+ import { type Storage as PylonStorage, type TransportConfig, type FileVisibility, type UploadBytesOptions, type UploadedFile } from "@pylonsync/sync";
18
18
  export { useQuery, useQueryOne, useReactiveQuery, useMutation, useInfiniteQuery, usePaginatedQuery, useEntityMutation, useAction, useQueryRaw, useQueryOneRaw, useLiveList, useLiveRow, useInsert, useUpdate, useDelete, useFn, useAggregate, useSearch, } from "./hooks";
19
19
  export type { QueryOptions, QueryFilter, IncludeSpec, UseQueryReturn, UseQueryOneReturn, UseReactiveQueryReturn, UseMutationReturn, UseInfiniteQueryReturn, UsePaginatedQueryReturn, PaginatedQueryStatus, UseFnReturn, AggregateSpec, UseAggregateReturn, SearchSpec, UseSearchReturn, } from "./hooks";
20
20
  export { useAgentRun } from "./useAgentRun";
@@ -32,6 +32,21 @@ export { useSession } from "./useSession";
32
32
  export type { UseSessionReturn, ResolvedSession } from "./useSession";
33
33
  export { useSyncStatus } from "./useSyncStatus";
34
34
  export type { SyncConnectionStatus } from "./useSyncStatus";
35
+ export { useSyncProgress } from "./useSyncProgress";
36
+ export type { SyncProgress } from "./useSyncProgress";
37
+ export { useOutbox } from "./useOutbox";
38
+ export type { UseOutboxReturn, Outbox, OutboxItem } from "./useOutbox";
39
+ export { queueUpload, useFileUrl } from "./offlineUploads";
40
+ export type { QueueUploadOptions, QueuedUploadHandle, UploadAttachTarget } from "./offlineUploads";
41
+ export { UploadQueueFullError, UploadRejectedError } from "@pylonsync/sync";
42
+ export { registerOfflineWorker } from "./offlineWorker";
43
+ export type { OfflineWorkerOptions } from "./offlineWorker";
44
+ export { useAppUpdate } from "./useAppUpdate";
45
+ export type { UseAppUpdateReturn } from "./useAppUpdate";
46
+ export { AppUpdateBar } from "./AppUpdateBar";
47
+ export type { AppUpdateBarProps } from "./AppUpdateBar";
48
+ export { createAppUpdate, AppUpdater } from "./appUpdate";
49
+ export type { AppBuild, AppUpdateState, AppUpdateEnv } from "./appUpdate";
35
50
  export { db, init, getSync, setSessionToken } from "./db";
36
51
  export { createTypedDb } from "./typed";
37
52
  export type { TypedDb, AgentDBSchema } from "./typed";
@@ -254,27 +269,9 @@ export declare function listFns(): Promise<{
254
269
  name: string;
255
270
  fn_type: "query" | "mutation" | "action";
256
271
  }[]>;
257
- export interface UploadedFile {
258
- id: string;
259
- /** Where to read the file. Store this on your row. */
260
- url: string;
261
- size: number;
262
- }
263
- /**
264
- * Who can read an uploaded file through `url`.
265
- * - "private" (default): only the uploader.
266
- * - "public": anyone, signed in or not. Use it for content every visitor
267
- * sees, such as feed photos or listing images.
268
- * Writes and deletes stay with the uploader either way. On a CDN-backed
269
- * store (Stack0, a public bucket) every file is served by its URL, so the
270
- * setting only changes reads that go through `/api/files/<id>`.
271
- */
272
- export type FileVisibility = "private" | "public";
273
- export interface UploadFileOptions {
274
- filename?: string;
275
- contentType?: string;
272
+ export type { UploadedFile, FileVisibility } from "@pylonsync/sync";
273
+ export interface UploadFileOptions extends UploadBytesOptions {
276
274
  token?: string;
277
- visibility?: FileVisibility;
278
275
  }
279
276
  /**
280
277
  * Upload a file (File, Blob, or raw bytes) in three steps:
@@ -287,6 +284,9 @@ export interface UploadFileOptions {
287
284
  * signature is the credential. The session is never sent to a presigned
288
285
  * URL on another origin.
289
286
  *
287
+ * All three steps need the network. For a file taken where there may be
288
+ * none (a photo in the field), use `queueUpload`.
289
+ *
290
290
  * @example
291
291
  * ```ts
292
292
  * const photo = await uploadFile(fileFromInput, { visibility: "public" });
@@ -0,0 +1,45 @@
1
+ import { type FileVisibility, type QueuedUploadHandle, type SyncEngine, type UploadAttachTarget } from "@pylonsync/sync";
2
+ export type { QueuedUploadHandle, UploadAttachTarget };
3
+ export interface QueueUploadOptions {
4
+ /** The record the file belongs to, and the field that gets its id
5
+ * (and `urlField`, its URL). */
6
+ attach: UploadAttachTarget;
7
+ filename?: string;
8
+ contentType?: string;
9
+ visibility?: FileVisibility;
10
+ /** Defaults to the engine `init()` set up. */
11
+ engine?: SyncEngine;
12
+ }
13
+ /**
14
+ * Keep a file on the device and upload it when there is a network, then
15
+ * set it on its record. Returns at once, also with no network.
16
+ *
17
+ * At once the record's `attach.field` (and `attach.urlField`) holds
18
+ * `local:<localId>`; read it with `useFileUrl` to show the photo from the
19
+ * device. When the upload finishes, the field gets the file id and
20
+ * `urlField` the file URL. Both changes are ordinary queued writes.
21
+ *
22
+ * ```ts
23
+ * const photoId = await db.insert("Photo", { leadId, fileId: null, url: null });
24
+ * await queueUpload(file, {
25
+ * attach: { entity: "Photo", id: photoId, field: "fileId", urlField: "url" },
26
+ * });
27
+ * ```
28
+ *
29
+ * Pending and failed uploads are in `useOutbox()` with kind `"upload"`.
30
+ * Throws `UploadQueueFullError` when the queue already holds
31
+ * `maxQueuedUploadBytes` (200 MB by default).
32
+ */
33
+ export declare function queueUpload(file: Blob, options: QueueUploadOptions): Promise<QueuedUploadHandle>;
34
+ /**
35
+ * The URL to show for a file field: the value itself, or, for a
36
+ * `local:<localId>` value from `queueUpload`, an object URL of the copy on
37
+ * this device. Null for an empty value, and for a `local:` value this
38
+ * device does not hold (another device queued it, or it was dismissed).
39
+ *
40
+ * ```tsx
41
+ * const src = useFileUrl(photo.url);
42
+ * return src ? <img src={src} /> : <Placeholder />;
43
+ * ```
44
+ */
45
+ export declare function useFileUrl(value: string | null | undefined, engine?: SyncEngine): string | null;
@@ -0,0 +1,54 @@
1
+ import { type AppBuild } from "./appUpdate";
2
+ export interface OfflineWorkerOptions {
3
+ /** The app set `buildManifest({ offline })`. */
4
+ optedIn: boolean;
5
+ /** Not `pylon dev`. */
6
+ production: boolean;
7
+ /** Whether a user is signed in on this page. The worker refetches the
8
+ * pages it keeps when this changes, because each one carries the
9
+ * signed-in bit. */
10
+ signedIn?: boolean;
11
+ /** Defaults to the global `navigator`. */
12
+ navigator?: {
13
+ serviceWorker?: ServiceWorkerContainerLike;
14
+ };
15
+ /** Resolves when the page has loaded, so the worker's downloads do not
16
+ * compete with the page's own. Defaults to the window `load` event. */
17
+ whenLoaded?: () => Promise<void>;
18
+ /** The build this page runs, from `__PYLON_DATA__`. `useAppUpdate`
19
+ * compares it with the latest build. */
20
+ build?: AppBuild;
21
+ }
22
+ /** The parts of `ServiceWorkerContainer` this module uses. */
23
+ export interface ServiceWorkerContainerLike {
24
+ register(url: string, options?: {
25
+ scope?: string;
26
+ }): Promise<unknown>;
27
+ getRegistrations?(): Promise<ReadonlyArray<RegistrationLike>>;
28
+ ready?: Promise<{
29
+ active?: {
30
+ postMessage(message: unknown): void;
31
+ } | null;
32
+ }>;
33
+ }
34
+ interface RegistrationLike {
35
+ active?: {
36
+ scriptURL: string;
37
+ } | null;
38
+ waiting?: {
39
+ scriptURL: string;
40
+ } | null;
41
+ installing?: {
42
+ scriptURL: string;
43
+ } | null;
44
+ unregister(): Promise<boolean>;
45
+ }
46
+ /**
47
+ * Register the worker when the app opted in and this is a production page.
48
+ * Otherwise remove a Pylon worker an earlier build left on this origin (an
49
+ * app that turned offline off, or `pylon dev` after `pylon start`). Workers
50
+ * the app registered itself are left alone. Never throws: an app works
51
+ * online without its worker.
52
+ */
53
+ export declare function registerOfflineWorker(opts: OfflineWorkerOptions): Promise<unknown | null>;
54
+ export {};
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Order two values the way the server's SQL does: negative, zero or
3
+ * positive; `null` when they cannot be compared (different types, or an
4
+ * object). Null and undefined are not handled here.
5
+ */
6
+ export declare function compareValues(a: unknown, b: unknown): number | null;
7
+ type Row = Record<string, unknown>;
8
+ /** A predicate for `where` (keys other than `$order` and `$limit`). */
9
+ export declare function compileWhere(where: Record<string, unknown>): (row: Row) => boolean;
10
+ /** Sort comparator for `$order` / `orderBy`: nulls last ascending, the
11
+ * server's value order otherwise. */
12
+ export declare function compareForOrder(a: unknown, b: unknown, dir: "asc" | "desc"): number;
13
+ export {};
@@ -0,0 +1,34 @@
1
+ import { type AppUpdater, type AppUpdateState } from "./appUpdate";
2
+ export interface UseAppUpdateReturn extends AppUpdateState {
3
+ /** Wait until the outbox has no pending writes, then load the newest
4
+ * build (the page reloads). */
5
+ apply: () => Promise<void>;
6
+ /** Ask the server for the latest build now. The hook also checks when
7
+ * the page becomes visible and every five minutes. */
8
+ check: () => Promise<void>;
9
+ }
10
+ /**
11
+ * Whether a newer build of the app is deployed, and a way to load it.
12
+ * Works in apps that set `buildManifest({ offline })`; elsewhere, under
13
+ * `pylon dev` and during SSR, `updateReady` stays false and `apply()` does
14
+ * nothing.
15
+ *
16
+ * ```tsx
17
+ * function UpdateNotice() {
18
+ * const { updateReady, applying, waitingForSync, apply } = useAppUpdate();
19
+ * if (!updateReady) return null;
20
+ * return (
21
+ * <div role="status">
22
+ * A new version of the app is available.
23
+ * <button onClick={apply} disabled={applying}>
24
+ * {waitingForSync ? "Saving changes" : "Update"}
25
+ * </button>
26
+ * </div>
27
+ * );
28
+ * }
29
+ * ```
30
+ *
31
+ * Pass an explicit `updater` (from `createAppUpdate`) to use one other than
32
+ * the page's.
33
+ */
34
+ export declare function useAppUpdate(updater?: AppUpdater): UseAppUpdateReturn;
@@ -0,0 +1,45 @@
1
+ import { type Outbox, type OutboxItem, type Row, type SyncEngine } from "@pylonsync/sync";
2
+ export type { Outbox, OutboxItem };
3
+ export interface UseOutboxReturn {
4
+ /** Writes the server has not applied yet: made offline, or waiting
5
+ * for a sign-in. Oldest first. */
6
+ pending: OutboxItem[];
7
+ /** Writes the server rejected, with `error` and `errorCode`. Oldest
8
+ * first. Each stays until it is retried or dismissed. */
9
+ failed: OutboxItem[];
10
+ /** Send a failed write again, with `data` merged over its fields.
11
+ * Resolves once the server applies it or it is queued offline;
12
+ * rejects with a `MutationRejectedError` when the server rejects it
13
+ * again or another user is signed in. */
14
+ retry: (id: string, data?: Row) => Promise<string>;
15
+ /** Drop a failed write. */
16
+ dismiss: (id: string) => boolean;
17
+ }
18
+ /**
19
+ * The writes on this device that are not on the server: pending writes
20
+ * (offline, or waiting for a sign-in) and failed writes (the server
21
+ * rejected them). Re-renders when a write is queued, applied, rejected,
22
+ * retried or dismissed.
23
+ *
24
+ * ```tsx
25
+ * function Outbox() {
26
+ * const { pending, failed, retry, dismiss } = useOutbox();
27
+ * return (
28
+ * <>
29
+ * {pending.length > 0 && <p>{pending.length} not synced</p>}
30
+ * {failed.map((w) => (
31
+ * <div key={w.id}>
32
+ * {w.entity}: {w.error}
33
+ * <button onClick={() => retry(w.id)}>Retry</button>
34
+ * <button onClick={() => dismiss(w.id)}>Dismiss</button>
35
+ * </div>
36
+ * ))}
37
+ * </>
38
+ * );
39
+ * }
40
+ * ```
41
+ *
42
+ * Pass an explicit `engine` if your app uses a non-default sync
43
+ * instance; otherwise the hook reads the singleton wired up by `init()`.
44
+ */
45
+ export declare function useOutbox(engine?: SyncEngine): UseOutboxReturn;
@@ -0,0 +1,21 @@
1
+ import { type SyncEngine, type SyncProgress } from "@pylonsync/sync";
2
+ export type { SyncProgress };
3
+ /**
4
+ * How far the sync engine's current pull has got: `pulling`, `snapshot`
5
+ * (a first sync or a far-behind catch-up), `rows` and `pages` received,
6
+ * and `synced` (a pull completed since the last replica reset).
7
+ *
8
+ * The session resolves as soon as `/api/auth/me` answers, before the
9
+ * replica has filled. Render the signed-in screen from `useSession()` and
10
+ * use this for a progress line while the data arrives. `useSyncStatus()`
11
+ * reports the connection instead.
12
+ *
13
+ * ```tsx
14
+ * function SyncLine() {
15
+ * const { pulling, snapshot, rows } = useSyncProgress();
16
+ * if (!pulling || !snapshot) return null;
17
+ * return <p>Loading data: {rows} records</p>;
18
+ * }
19
+ * ```
20
+ */
21
+ export declare function useSyncProgress(engine?: SyncEngine): SyncProgress;
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.22.47",
6
+ "version": "0.22.58",
7
7
  "type": "module",
8
8
  "main": "./src/index.ts",
9
9
  "types": "./dist/index.d.ts",
@@ -14,9 +14,9 @@
14
14
  "prepack": "bun run build"
15
15
  },
16
16
  "dependencies": {
17
- "@pylonsync/realtime": "0.22.47",
18
- "@pylonsync/sdk": "0.22.47",
19
- "@pylonsync/sync": "0.22.47"
17
+ "@pylonsync/realtime": "0.22.58",
18
+ "@pylonsync/sdk": "0.22.58",
19
+ "@pylonsync/sync": "0.22.58"
20
20
  },
21
21
  "peerDependencies": {
22
22
  "react": ">=19.0.0"
@@ -0,0 +1,54 @@
1
+ "use client";
2
+
3
+ import type { ReactNode } from "react";
4
+ import type { AppUpdater } from "./appUpdate";
5
+ import { useAppUpdate } from "./useAppUpdate";
6
+
7
+ export interface AppUpdateBarProps {
8
+ className?: string;
9
+ /** Text when a new build is ready. */
10
+ message?: ReactNode;
11
+ /** The button label. */
12
+ actionLabel?: ReactNode;
13
+ /** The button label while pending writes are sent before the reload. */
14
+ savingLabel?: ReactNode;
15
+ /** Text on an iOS home-screen app whose name or icon changed. */
16
+ reinstallMessage?: ReactNode;
17
+ updater?: AppUpdater;
18
+ }
19
+
20
+ /**
21
+ * A minimal, unstyled notice for `useAppUpdate`. Renders nothing until a
22
+ * new build is ready or the home-screen app must be added again. Style it
23
+ * with `className` and the `data-pylon-app-update` attribute, or build your
24
+ * own notice with the hook.
25
+ */
26
+ export function AppUpdateBar({
27
+ className,
28
+ message = "A new version of the app is available.",
29
+ actionLabel = "Update",
30
+ savingLabel = "Saving changes",
31
+ reinstallMessage = "The app name or icon changed. Remove the app from the home screen and add it again.",
32
+ updater,
33
+ }: AppUpdateBarProps) {
34
+ const { updateReady, reinstallRecommended, applying, waitingForSync, apply } =
35
+ useAppUpdate(updater);
36
+ if (updateReady) {
37
+ return (
38
+ <div role="status" className={className} data-pylon-app-update="ready">
39
+ <span>{message}</span>{" "}
40
+ <button type="button" onClick={() => void apply()} disabled={applying}>
41
+ {waitingForSync ? savingLabel : actionLabel}
42
+ </button>
43
+ </div>
44
+ );
45
+ }
46
+ if (reinstallRecommended) {
47
+ return (
48
+ <div role="status" className={className} data-pylon-app-update="reinstall">
49
+ {reinstallMessage}
50
+ </div>
51
+ );
52
+ }
53
+ return null;
54
+ }
package/src/Link.tsx CHANGED
@@ -9,9 +9,16 @@
9
9
  // that match across the two routes survive reconciliation, so
10
10
  // their state persists.
11
11
  // - Prefetches in two stages, because the two halves cost very
12
- // different things. On viewport entry (IntersectionObserver) the
13
- // runtime modulepreloads the destination's client chunks — static,
14
- // immutable, cacheable. On hover, touch, or focus it also fetches
12
+ // different things. For every rendered link, the runtime
13
+ // modulepreloads the destination's client chunks — static, immutable,
14
+ // cacheable — once the page has painted and the browser is idle
15
+ // (requestIdleCallback), and not at all under Save-Data or on 2G. That
16
+ // covers links off screen, such as a tab bar, so the first tap is warm
17
+ // too, without counting against this page's first paint. For a route
18
+ // that navigates with no page fetch (`navigation = "client"` or an
19
+ // offline route), the runtime also loads its entry then. On hover,
20
+ // touch, pointerdown, or focus it fetches the chunks at once and also
21
+ // fetches
15
22
  // the page itself, which costs a server render, so that waits for a
16
23
  // real signal of intent rather than firing for a screenful of links.
17
24
  // Focus is included so a keyboard user tabbing to a link gets the
@@ -138,6 +145,12 @@ export function Link({
138
145
  window.__pylon?.prefetch(href, { document: true });
139
146
  };
140
147
 
148
+ // Every rendered link, not only those in view, once the browser is idle.
149
+ const idle =
150
+ typeof window.requestIdleCallback === "function"
151
+ ? window.requestIdleCallback(warmChunks, { timeout: 2000 })
152
+ : window.setTimeout(warmChunks, 200);
153
+
141
154
  let io: IntersectionObserver | null = null;
142
155
  if (typeof IntersectionObserver !== "undefined") {
143
156
  io = new IntersectionObserver(
@@ -156,6 +169,8 @@ export function Link({
156
169
 
157
170
  el.addEventListener("mouseenter", warmPage);
158
171
  el.addEventListener("touchstart", warmPage, { passive: true });
172
+ // A press starts ~100 ms before the click: enough to start the fetch.
173
+ el.addEventListener("pointerdown", warmPage);
159
174
  // Focus is the keyboard's version of hover, and a stronger signal: a
160
175
  // cursor slides across links it isn't aiming at, but nobody tabs onto one
161
176
  // idly. Without it, tabbing to a link and pressing Enter took the cold
@@ -163,9 +178,12 @@ export function Link({
163
178
  el.addEventListener("focus", warmPage);
164
179
 
165
180
  return () => {
181
+ if (typeof window.cancelIdleCallback === "function") window.cancelIdleCallback(idle);
182
+ else window.clearTimeout(idle);
166
183
  io?.disconnect();
167
184
  el.removeEventListener("mouseenter", warmPage);
168
185
  el.removeEventListener("touchstart", warmPage);
186
+ el.removeEventListener("pointerdown", warmPage);
169
187
  el.removeEventListener("focus", warmPage);
170
188
  };
171
189
  }, [href, prefetch]);