@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.
- package/dist/AppUpdateBar.d.ts +21 -0
- package/dist/appUpdate.d.ts +166 -0
- package/dist/db.d.ts +4 -0
- package/dist/index.d.ts +21 -21
- package/dist/offlineUploads.d.ts +45 -0
- package/dist/offlineWorker.d.ts +54 -0
- package/dist/queryFilter.d.ts +13 -0
- package/dist/useAppUpdate.d.ts +34 -0
- package/dist/useOutbox.d.ts +45 -0
- package/dist/useSyncProgress.d.ts +21 -0
- package/package.json +4 -4
- package/src/AppUpdateBar.tsx +54 -0
- package/src/Link.tsx +21 -3
- package/src/appUpdate.test.ts +461 -0
- package/src/appUpdate.ts +602 -0
- package/src/db.ts +11 -0
- package/src/hooks.ts +2 -68
- package/src/index.ts +35 -102
- package/src/offlineUploads.test.tsx +66 -0
- package/src/offlineUploads.ts +77 -0
- package/src/offlineWorker.test.ts +110 -0
- package/src/offlineWorker.ts +103 -0
- package/src/query-filter-cases.json +23 -0
- package/src/queryFilter.test.ts +45 -0
- package/src/queryFilter.ts +138 -0
- package/src/useAppUpdate.test.tsx +98 -0
- package/src/useAppUpdate.ts +49 -0
- package/src/useOutbox.test.tsx +82 -0
- package/src/useOutbox.ts +74 -0
- package/src/useSession.ts +4 -2
- package/src/useSyncProgress.test.tsx +37 -0
- package/src/useSyncProgress.ts +46 -0
|
@@ -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
|
|
258
|
-
|
|
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.
|
|
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.
|
|
18
|
-
"@pylonsync/sdk": "0.22.
|
|
19
|
-
"@pylonsync/sync": "0.22.
|
|
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.
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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]);
|