shelving 1.273.0 → 1.274.0

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.
@@ -4,6 +4,7 @@ import type { Identifier, Item } from "../../util/item.js";
4
4
  import type { Query } from "../../util/query.js";
5
5
  import type { Updates } from "../../util/update.js";
6
6
  import type { Collection } from "../collection/Collection.js";
7
+ import type { DBProvider } from "./DBProvider.js";
7
8
  import { ThroughDBProvider } from "./ThroughDBProvider.js";
8
9
  /**
9
10
  * Structured log entry recording a single database write performed through a `ChangesDBProvider`.
@@ -50,4 +51,6 @@ export declare class ChangesDBProvider<I extends Identifier, T extends Data> ext
50
51
  updateQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>, updates: Updates<TT>): Promise<void>;
51
52
  /** Log a `"delete"` change after writing. */
52
53
  deleteQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>): Promise<void>;
54
+ cloneWith(source: DBProvider<I, T>): this;
55
+ transact<X>(callback: (provider: DBProvider<I, T>) => Promise<X>): Promise<X>;
53
56
  }
@@ -53,4 +53,19 @@ export class ChangesDBProvider extends ThroughDBProvider {
53
53
  await super.deleteQuery(collection, query);
54
54
  this._changes.push({ action: "delete", collection: collection.name, query });
55
55
  }
56
+ // Override so that transaction copies get their own log.
57
+ cloneWith(source) {
58
+ const clone = super.cloneWith(source);
59
+ Object.defineProperty(clone, "_changes", { value: [], enumerable: false });
60
+ return clone;
61
+ }
62
+ // Override to log the transaction's writes after it commits — a failed transaction logs nothing.
63
+ // The merge must happen after `source.transact()` resolves: backends may retry the callback or fail the commit itself, and only the committed attempt's writes belong in the log.
64
+ async transact(callback) {
65
+ let transaction;
66
+ const result = await this.source.transact(provider => callback((transaction = this.cloneWith(provider))));
67
+ if (transaction)
68
+ this._changes.push(...transaction.changes);
69
+ return result;
70
+ }
56
71
  }
@@ -167,5 +167,22 @@ export declare abstract class DBProvider<I extends Identifier = Identifier, T ex
167
167
  * @see https://shelving.cc/db/DBProvider/requireFirst
168
168
  */
169
169
  requireFirst<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>): Promise<Item<II, TT>>;
170
+ /**
171
+ * Run a callback as a single atomic transaction — every write made through the callback's provider is committed together, or not at all.
172
+ *
173
+ * - The callback receives a transaction-scoped `DBProvider`; reads and writes made through it belong to the transaction.
174
+ * - Reads see a consistent snapshot of the data from before the transaction, and do not see the transaction's own uncommitted writes.
175
+ * - If the callback throws, nothing is committed and the error is rethrown.
176
+ * - The callback may run more than once if the backend retries on contention, so it must have no side effects other than through its provider.
177
+ * - Inside a transaction, realtime sequences and nested `transact()` calls throw `UnsupportedError`.
178
+ * - Not every provider supports transactions — the base implementation throws `UnsupportedError`.
179
+ *
180
+ * @param callback Function that performs the transaction's reads and writes through the provider it receives.
181
+ * @returns The value returned by the callback.
182
+ * @throws `UnsupportedError` if this provider does not support transactions.
183
+ * @example await provider.transact(async db => void (await db.updateItem(users, 123, { logins: { sum: 1 } })));
184
+ * @see https://shelving.cc/db/DBProvider/transact
185
+ */
186
+ transact<X>(callback: (provider: DBProvider<I, T>) => Promise<X>): Promise<X>;
170
187
  [Symbol.asyncDispose](): Promise<void>;
171
188
  }
@@ -1,4 +1,5 @@
1
1
  import { RequiredError } from "../../error/RequiredError.js";
2
+ import { UnsupportedError } from "../../error/UnsupportedError.js";
2
3
  import { countArray, getFirst } from "../../util/array.js";
3
4
  import { awaitDispose } from "../../util/dispose.js";
4
5
  /**
@@ -81,6 +82,29 @@ export class DBProvider {
81
82
  });
82
83
  return first;
83
84
  }
85
+ /**
86
+ * Run a callback as a single atomic transaction — every write made through the callback's provider is committed together, or not at all.
87
+ *
88
+ * - The callback receives a transaction-scoped `DBProvider`; reads and writes made through it belong to the transaction.
89
+ * - Reads see a consistent snapshot of the data from before the transaction, and do not see the transaction's own uncommitted writes.
90
+ * - If the callback throws, nothing is committed and the error is rethrown.
91
+ * - The callback may run more than once if the backend retries on contention, so it must have no side effects other than through its provider.
92
+ * - Inside a transaction, realtime sequences and nested `transact()` calls throw `UnsupportedError`.
93
+ * - Not every provider supports transactions — the base implementation throws `UnsupportedError`.
94
+ *
95
+ * @param callback Function that performs the transaction's reads and writes through the provider it receives.
96
+ * @returns The value returned by the callback.
97
+ * @throws `UnsupportedError` if this provider does not support transactions.
98
+ * @example await provider.transact(async db => void (await db.updateItem(users, 123, { logins: { sum: 1 } })));
99
+ * @see https://shelving.cc/db/DBProvider/transact
100
+ */
101
+ transact(callback) {
102
+ throw new UnsupportedError(`${this.constructor.name} does not support transactions`, {
103
+ provider: this,
104
+ received: callback,
105
+ caller: this.transact,
106
+ });
107
+ }
84
108
  // Implement `AsyncDisposable`
85
109
  async [Symbol.asyncDispose]() {
86
110
  await awaitDispose(
@@ -3,6 +3,7 @@ import type { Identifier, Item, Items, ItemsSequence, OptionalItem, OptionalItem
3
3
  import type { Query } from "../../util/query.js";
4
4
  import type { Updates } from "../../util/update.js";
5
5
  import type { Collection } from "../collection/Collection.js";
6
+ import type { DBProvider } from "./DBProvider.js";
6
7
  import { ThroughDBProvider } from "./ThroughDBProvider.js";
7
8
  /**
8
9
  * Database provider that logs every operation it performs to the console for debugging.
@@ -29,4 +30,6 @@ export declare class DebugDBProvider<I extends Identifier, T extends Data> exten
29
30
  setQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>, data: TT): Promise<void>;
30
31
  updateQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>, updates: Updates<TT>): Promise<void>;
31
32
  deleteQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>): Promise<void>;
33
+ /** Adds TRANSACT logging around the base behaviour, which re-wraps so operations inside the transaction are logged too. */
34
+ transact<X>(callback: (provider: DBProvider<I, T>) => Promise<X>): Promise<X>;
32
35
  }
@@ -153,4 +153,17 @@ export class DebugDBProvider extends ThroughDBProvider {
153
153
  throw reason;
154
154
  }
155
155
  }
156
+ /** Adds TRANSACT logging around the base behaviour, which re-wraps so operations inside the transaction are logged too. */
157
+ async transact(callback) {
158
+ try {
159
+ console.debug(`${ANSI_RIGHT} TRANSACT`);
160
+ const result = await super.transact(callback);
161
+ console.debug(`${ANSI_SUCCESS} TRANSACT`);
162
+ return result;
163
+ }
164
+ catch (reason) {
165
+ console.error(`${ANSI_FAILURE} TRANSACT`, reason);
166
+ throw reason;
167
+ }
168
+ }
156
169
  }
@@ -36,5 +36,8 @@ export declare class ThroughDBProvider<I extends Identifier, T extends Data> imp
36
36
  deleteQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>): Promise<void>;
37
37
  getFirst<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>): Promise<OptionalItem<II, TT>>;
38
38
  requireFirst<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>): Promise<Item<II, TT>>;
39
+ transact<X>(callback: (provider: DBProvider<I, T>) => Promise<X>): Promise<X>;
40
+ /** Clone this provider with different `source`. */
41
+ cloneWith(source: DBProvider<I, T>): this;
39
42
  [Symbol.asyncDispose](): Promise<void>;
40
43
  }
@@ -62,6 +62,14 @@ export class ThroughDBProvider {
62
62
  requireFirst(collection, query) {
63
63
  return this.source.requireFirst(collection, query);
64
64
  }
65
+ // Run the transaction against the wrapped `source` provider, keeping this provider's behaviour inside the transaction.
66
+ transact(callback) {
67
+ return this.source.transact(transaction => callback(this.cloneWith(transaction)));
68
+ }
69
+ /** Clone this provider with different `source`. */
70
+ cloneWith(source) {
71
+ return Object.create(this, { source: { value: source, enumerable: true } });
72
+ }
65
73
  // Implement `AsyncDisposable`
66
74
  async [Symbol.asyncDispose]() {
67
75
  await awaitDispose(this.source);
@@ -10,6 +10,7 @@ import { ThroughDBProvider } from "./ThroughDBProvider.js";
10
10
  * - Wraps a `source` provider (which may have any type, because validation guarantees the type) and runs every value through the relevant `Collection` schema before writing and after reading.
11
11
  * - Written data is validated against the collection's data schema; read data is validated against the item schema, so trusted, correctly-typed values reach the rest of the app.
12
12
  * - Validation failures here are program-state errors, so they throw a typed `ValueError` rather than a raw validation `string`.
13
+ * - Applies inside `transact()` too — the transaction provider the callback receives validates its reads and writes the same way.
13
14
  *
14
15
  * @see https://shelving.cc/db/ValidationDBProvider
15
16
  */
@@ -8,6 +8,7 @@ import { ThroughDBProvider } from "./ThroughDBProvider.js";
8
8
  * - Wraps a `source` provider (which may have any type, because validation guarantees the type) and runs every value through the relevant `Collection` schema before writing and after reading.
9
9
  * - Written data is validated against the collection's data schema; read data is validated against the item schema, so trusted, correctly-typed values reach the rest of the app.
10
10
  * - Validation failures here are program-state errors, so they throw a typed `ValueError` rather than a raw validation `string`.
11
+ * - Applies inside `transact()` too — the transaction provider the callback receives validates its reads and writes the same way.
11
12
  *
12
13
  * @see https://shelving.cc/db/ValidationDBProvider
13
14
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shelving",
3
- "version": "1.273.0",
3
+ "version": "1.274.0",
4
4
  "author": "Dave Houlbrooke <dave@shax.com>",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,15 @@
1
+ import type { DBProvider } from "../db/provider/DBProvider.js";
2
+ import { MemoryDBProvider } from "../db/provider/MemoryDBProvider.js";
3
+ import type { Data } from "../util/data.js";
4
+ import type { Identifier } from "../util/item.js";
5
+ /**
6
+ * In-memory provider for testing wrapping providers' `transact()` — runs the callback directly against itself.
7
+ *
8
+ * - No atomicity or rollback: writes apply immediately and are kept even if the callback throws.
9
+ * - Use it as the `source` of a wrapping provider to test how the wrapper behaves inside transactions, not to test transaction semantics themselves.
10
+ *
11
+ * @see https://shelving.cc/test/TransactionTestDBProvider
12
+ */
13
+ export declare class TransactionTestDBProvider<I extends Identifier = Identifier, T extends Data = Data> extends MemoryDBProvider<I, T> {
14
+ transact<X>(callback: (provider: DBProvider<I, T>) => Promise<X>): Promise<X>;
15
+ }
@@ -0,0 +1,14 @@
1
+ import { MemoryDBProvider } from "../db/provider/MemoryDBProvider.js";
2
+ /**
3
+ * In-memory provider for testing wrapping providers' `transact()` — runs the callback directly against itself.
4
+ *
5
+ * - No atomicity or rollback: writes apply immediately and are kept even if the callback throws.
6
+ * - Use it as the `source` of a wrapping provider to test how the wrapper behaves inside transactions, not to test transaction semantics themselves.
7
+ *
8
+ * @see https://shelving.cc/test/TransactionTestDBProvider
9
+ */
10
+ export class TransactionTestDBProvider extends MemoryDBProvider {
11
+ async transact(callback) {
12
+ return await callback(this);
13
+ }
14
+ }
package/test/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Collection } from "../db/collection/Collection.js";
2
2
  export * from "./basics.js";
3
3
  export * from "./people.js";
4
+ export * from "./TransactionTestDBProvider.js";
4
5
  export * from "./util.js";
5
6
  export declare const BASICS_COLLECTION: Collection<"basics", string, {
6
7
  str: string;
package/test/index.js CHANGED
@@ -4,6 +4,7 @@ import { BASIC_SCHEMA } from "./basics.js";
4
4
  import { PERSON_SCHEMA } from "./people.js";
5
5
  export * from "./basics.js";
6
6
  export * from "./people.js";
7
+ export * from "./TransactionTestDBProvider.js";
7
8
  export * from "./util.js";
8
9
  export const BASICS_COLLECTION = new Collection("basics", STRING, BASIC_SCHEMA);
9
10
  export const PEOPLE_COLLECTION = new Collection("people", STRING, PERSON_SCHEMA);
package/ui/app/App.md CHANGED
@@ -46,6 +46,8 @@ export function MyApp() {
46
46
  }
47
47
  ```
48
48
 
49
+ **Setting a `root` URL on `<App>` is strongly recommended.** `root` is the base that site-absolute links and assets (`/favicon.png`, `/base.css`) resolve against, the `<base>` tag rendered by `<HTML>`, and the prefix `<Router>` strips when matching routes. When it's unset, merging a `url` into the meta context derives `root` from that URL's origin (e.g. `https://example.com/`) — a safe fallback for apps served from the domain root, but wrong for apps deployed under a sub-path, so set it explicitly whenever your app doesn't live at `/`.
50
+
49
51
  `<App>` accepts all `PossibleMeta` props (`app`, `root`, `url`, `title`, `language`, `tags`, etc.) and merges them into the context it provides to children. On mount it adds the theme class to `document.body`, which activates the CSS custom property tokens defined in `App.module.css`; on unmount it removes it.
50
52
 
51
53
  For a documentation site, hand an extracted tree to `<TreeApp>` instead — see the `shelving/extract` guide.
@@ -28,6 +28,7 @@ export declare function requireMeta(meta?: PossibleMeta): Meta;
28
28
  */
29
29
  export interface MetaURL extends Meta {
30
30
  url: ImmutableURL;
31
+ root: ImmutableURL;
31
32
  /** The path of `url` relative to `meta.root` (i.e. the _site-root-relative_ path). */
32
33
  path: AbsolutePath;
33
34
  /** The `?query` params of `url` extracted as a flat set of parameters. */
@@ -38,9 +39,9 @@ export interface MetaURL extends Meta {
38
39
  *
39
40
  * @param meta A set of new possible meta data to combine into the current meta context.
40
41
  * @param caller Function to attribute thrown `RequiredError`s to (defaults to `requireMetaURL`).
41
- * @returns A `Meta` object with a defined `url`, plus `path` and `params` properties combined in.
42
- * @throws RequiredError If the current meta has no `url`.
43
- * @throws RequiredError If the current meta `url` does not share an origin with the meta `root`.
42
+ * @returns A `Meta` object with a defined `url` and `root`, plus `path` and `params` properties combined in.
43
+ * @throws RequiredError If the current meta has no `url` or no `root`.
44
+ * @throws RequiredError If the current meta `url` and `root` do not share a root (different origin, or `url` outside `root`'s path).
44
45
  * @example const { path, params } = requireMetaURL();
45
46
  * @see https://shelving.cc/ui/requireMetaURL
46
47
  */
@@ -30,9 +30,9 @@ export function requireMeta(meta) {
30
30
  *
31
31
  * @param meta A set of new possible meta data to combine into the current meta context.
32
32
  * @param caller Function to attribute thrown `RequiredError`s to (defaults to `requireMetaURL`).
33
- * @returns A `Meta` object with a defined `url`, plus `path` and `params` properties combined in.
34
- * @throws RequiredError If the current meta has no `url`.
35
- * @throws RequiredError If the current meta `url` does not share an origin with the meta `root`.
33
+ * @returns A `Meta` object with a defined `url` and `root`, plus `path` and `params` properties combined in.
34
+ * @throws RequiredError If the current meta has no `url` or no `root`.
35
+ * @throws RequiredError If the current meta `url` and `root` do not share a root (different origin, or `url` outside `root`'s path).
36
36
  * @example const { path, params } = requireMetaURL();
37
37
  * @see https://shelving.cc/ui/requireMetaURL
38
38
  */
@@ -40,9 +40,11 @@ export function requireMetaURL(meta, caller = requireMetaURL) {
40
40
  const { url, root, ...combined } = requireMeta(meta);
41
41
  if (!url)
42
42
  throw new RequiredError("Meta URL is required", { received: url, caller });
43
+ if (!root)
44
+ throw new RequiredError("Meta root is required", { received: root, caller });
43
45
  const path = matchURLPrefix(url, root, caller);
44
46
  if (!path)
45
- throw new RequiredError("Meta URL and meta root must share an origin", { url, root, caller });
47
+ throw new RequiredError("Meta URL and meta root must share a root", { url, root, caller });
46
48
  const params = getURIParams(url, caller);
47
49
  return { ...combined, url, root, path, params };
48
50
  }
@@ -1,8 +1,8 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type { ReactNode } from "react";
3
3
  import { renderToStaticMarkup } from "react-dom/server";
4
- import { RequiredError } from "shelving/error";
5
4
  import { createMeta, MetaContext, requireMetaURL } from "shelving/ui";
5
+ import { requireURL } from "shelving/util/url";
6
6
 
7
7
  /** Render `requireMetaURL().path` from inside a component so its `use(MetaContext)` call is valid. */
8
8
  function Probe(): ReactNode {
@@ -38,8 +38,28 @@ describe("requireMetaURL", () => {
38
38
  expect(html).toBe("/enquiry/loan");
39
39
  });
40
40
 
41
+ test("defaults root to the url's origin when root is unset", () => {
42
+ const html = renderToStaticMarkup(
43
+ <MetaContext value={createMeta({ url: "http://x.com/enquiry/loan" })}>
44
+ <Probe />
45
+ </MetaContext>,
46
+ );
47
+ expect(html).toBe("/enquiry/loan");
48
+ });
49
+
41
50
  test("throws RequiredError when url is unset", () => {
42
- expect(() => renderToStaticMarkup(<Probe />)).toThrow(RequiredError);
51
+ expect(() => renderToStaticMarkup(<Probe />)).toThrow("Meta URL is required");
52
+ });
53
+
54
+ test("throws RequiredError when root is unset and was not derived", () => {
55
+ // Bypass `createMeta()` deliberately — merged meta always derives a root, so this backup check only fires for hand-built meta.
56
+ expect(() =>
57
+ renderToStaticMarkup(
58
+ <MetaContext value={{ url: requireURL("http://x.com/foo") }}>
59
+ <Probe />
60
+ </MetaContext>,
61
+ ),
62
+ ).toThrow("Meta root is required");
43
63
  });
44
64
 
45
65
  test("throws RequiredError when url and root are on different origins", () => {
@@ -49,6 +69,16 @@ describe("requireMetaURL", () => {
49
69
  <Probe />
50
70
  </MetaContext>,
51
71
  ),
52
- ).toThrow(RequiredError);
72
+ ).toThrow("Meta URL and meta root must share a root");
73
+ });
74
+
75
+ test("throws RequiredError when url is outside the root's path", () => {
76
+ expect(() =>
77
+ renderToStaticMarkup(
78
+ <MetaContext value={createMeta({ root: "http://x.com/app/", url: "http://x.com/other" })}>
79
+ <Probe />
80
+ </MetaContext>,
81
+ ),
82
+ ).toThrow("Meta URL and meta root must share a root");
53
83
  });
54
84
  });
@@ -37,6 +37,7 @@ export function requireMeta(meta?: PossibleMeta): Meta {
37
37
  */
38
38
  export interface MetaURL extends Meta {
39
39
  url: ImmutableURL;
40
+ root: ImmutableURL;
40
41
  /** The path of `url` relative to `meta.root` (i.e. the _site-root-relative_ path). */
41
42
  path: AbsolutePath;
42
43
  /** The `?query` params of `url` extracted as a flat set of parameters. */
@@ -48,17 +49,18 @@ export interface MetaURL extends Meta {
48
49
  *
49
50
  * @param meta A set of new possible meta data to combine into the current meta context.
50
51
  * @param caller Function to attribute thrown `RequiredError`s to (defaults to `requireMetaURL`).
51
- * @returns A `Meta` object with a defined `url`, plus `path` and `params` properties combined in.
52
- * @throws RequiredError If the current meta has no `url`.
53
- * @throws RequiredError If the current meta `url` does not share an origin with the meta `root`.
52
+ * @returns A `Meta` object with a defined `url` and `root`, plus `path` and `params` properties combined in.
53
+ * @throws RequiredError If the current meta has no `url` or no `root`.
54
+ * @throws RequiredError If the current meta `url` and `root` do not share a root (different origin, or `url` outside `root`'s path).
54
55
  * @example const { path, params } = requireMetaURL();
55
56
  * @see https://shelving.cc/ui/requireMetaURL
56
57
  */
57
58
  export function requireMetaURL(meta?: PossibleMeta, caller: AnyCaller = requireMetaURL): MetaURL {
58
59
  const { url, root, ...combined } = requireMeta(meta);
59
60
  if (!url) throw new RequiredError("Meta URL is required", { received: url, caller });
61
+ if (!root) throw new RequiredError("Meta root is required", { received: root, caller });
60
62
  const path = matchURLPrefix(url, root, caller);
61
- if (!path) throw new RequiredError("Meta URL and meta root must share an origin", { url, root, caller });
63
+ if (!path) throw new RequiredError("Meta URL and meta root must share a root", { url, root, caller });
62
64
  const params = getURIParams(url, caller);
63
65
  return { ...combined, url, root, path, params };
64
66
  }
@@ -1,5 +1,5 @@
1
1
  import { type ReactElement } from "react";
2
- import type { PossibleMeta } from "../util/index.js";
2
+ import { type PossibleMeta } from "../util/index.js";
3
3
  import type { OptionalChildProps } from "../util/props.js";
4
4
  /**
5
5
  * Props for `<Navigation>` — initial `Meta` (url/base) plus optional `children`.
@@ -13,7 +13,7 @@ export interface NavigationProps extends PossibleMeta, OptionalChildProps {
13
13
  * - Owns a single `NavigationStore` initialised from the surrounding `<Meta>` url/base.
14
14
  * - Intercepts same-origin anchor clicks (excluding `download` anchors) and turns them into `forward()` calls.
15
15
  * - Listens for `popstate` to sync the store with browser back/forward.
16
- * - Publishes the live URL via `<Meta url={…} params={…}>` so descendant `<Router>`s re-render on navigation.
16
+ * - Publishes the live URL into the `Meta` context via `mergeMeta()`, so descendant `<Router>`s re-render on navigation and merge invariants hold (e.g. `root` defaults to the live URL's origin when unset).
17
17
  *
18
18
  * Exactly one `<Navigation>` per app — nested routers share this single store.
19
19
  *
@@ -3,6 +3,7 @@ import { useEffect } from "react";
3
3
  import { useInstance } from "../../react/useInstance.js";
4
4
  import { useStore } from "../../react/useStore.js";
5
5
  import { MetaContext, requireMeta } from "../misc/MetaContext.js";
6
+ import { mergeMeta } from "../util/index.js";
6
7
  import { NavigationContext } from "./NavigationContext.js";
7
8
  import { NavigationStore } from "./NavigationStore.js";
8
9
  /**
@@ -10,7 +11,7 @@ import { NavigationStore } from "./NavigationStore.js";
10
11
  * - Owns a single `NavigationStore` initialised from the surrounding `<Meta>` url/base.
11
12
  * - Intercepts same-origin anchor clicks (excluding `download` anchors) and turns them into `forward()` calls.
12
13
  * - Listens for `popstate` to sync the store with browser back/forward.
13
- * - Publishes the live URL via `<Meta url={…} params={…}>` so descendant `<Router>`s re-render on navigation.
14
+ * - Publishes the live URL into the `Meta` context via `mergeMeta()`, so descendant `<Router>`s re-render on navigation and merge invariants hold (e.g. `root` defaults to the live URL's origin when unset).
14
15
  *
15
16
  * Exactly one `<Navigation>` per app — nested routers share this single store.
16
17
  *
@@ -20,8 +21,8 @@ import { NavigationStore } from "./NavigationStore.js";
20
21
  * @see https://shelving.cc/ui/Navigation
21
22
  */
22
23
  export function Navigation({ children, ...meta }) {
23
- const { url, root, ...merged } = requireMeta(meta);
24
- const nav = useInstance(NavigationStore, url, root);
24
+ const current = requireMeta(meta);
25
+ const nav = useInstance(NavigationStore, current.url, current.root);
25
26
  useStore(nav);
26
27
  useEffect(() => {
27
28
  if (typeof document === "undefined" || typeof window === "undefined")
@@ -47,5 +48,5 @@ export function Navigation({ children, ...meta }) {
47
48
  window.removeEventListener("popstate", onPopState);
48
49
  };
49
50
  }, [nav]);
50
- return (_jsx(NavigationContext, { value: nav, children: _jsx(MetaContext, { value: { url: nav.value, root, ...merged }, children: children }) }));
51
+ return (_jsx(NavigationContext, { value: nav, children: _jsx(MetaContext, { value: mergeMeta(current, { url: nav.value }, Navigation), children: children }) }));
51
52
  }
@@ -6,7 +6,8 @@ The top-level navigation provider for a client-side app. It owns a single `Navig
6
6
 
7
7
  - Same-origin anchor clicks are intercepted automatically and turned into `forward()` calls. Add a `download` attribute to an anchor to opt out.
8
8
  - It listens for `popstate` so the store stays in sync with browser back/forward.
9
- - It initialises the store from the surrounding `<Meta>` url/base, so set those on an ancestor `<HTML>` / `<Page>`.
9
+ - It initialises the store from the surrounding `<Meta>` url/base, so set those on an ancestor `<App>` / `<HTML>` / `<Page>`. In the browser the store falls back to `window.location.href` when no url is set.
10
+ - The meta it publishes always has a `root` — when none is set anywhere, `root` defaults to the live URL's origin. Setting `root` explicitly on `<App>` / `<HTML>` is still strongly recommended, especially for apps served under a sub-path.
10
11
  - `<Router>` works with no `<Navigation>` at all (SSR, static rendering, tests) — `<Navigation>` is only what makes the URL *live* on the client.
11
12
 
12
13
  ## Usage
@@ -0,0 +1,29 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ReactNode } from "react";
3
+ import { renderToStaticMarkup } from "react-dom/server";
4
+ import { Navigation, requireMetaURL } from "shelving/ui";
5
+
6
+ /** Render `requireMetaURL().path` from inside a component so its `use(MetaContext)` call is valid. */
7
+ function Probe(): ReactNode {
8
+ return requireMetaURL().path;
9
+ }
10
+
11
+ describe("Navigation", () => {
12
+ test("publishes merged meta with a root derived from the url", () => {
13
+ const html = renderToStaticMarkup(
14
+ <Navigation url="http://x.com/a/b">
15
+ <Probe />
16
+ </Navigation>,
17
+ );
18
+ expect(html).toBe("/a/b");
19
+ });
20
+
21
+ test("resolves a relative url against an explicit root", () => {
22
+ const html = renderToStaticMarkup(
23
+ <Navigation root="http://x.com/app/" url="./sub">
24
+ <Probe />
25
+ </Navigation>,
26
+ );
27
+ expect(html).toBe("/sub");
28
+ });
29
+ });
@@ -2,7 +2,7 @@ import { type ReactElement, useEffect } from "react";
2
2
  import { useInstance } from "../../react/useInstance.js";
3
3
  import { useStore } from "../../react/useStore.js";
4
4
  import { MetaContext, requireMeta } from "../misc/MetaContext.js";
5
- import type { PossibleMeta } from "../util/index.js";
5
+ import { mergeMeta, type PossibleMeta } from "../util/index.js";
6
6
  import type { OptionalChildProps } from "../util/props.js";
7
7
  import { NavigationContext } from "./NavigationContext.js";
8
8
  import { NavigationStore } from "./NavigationStore.js";
@@ -19,7 +19,7 @@ export interface NavigationProps extends PossibleMeta, OptionalChildProps {}
19
19
  * - Owns a single `NavigationStore` initialised from the surrounding `<Meta>` url/base.
20
20
  * - Intercepts same-origin anchor clicks (excluding `download` anchors) and turns them into `forward()` calls.
21
21
  * - Listens for `popstate` to sync the store with browser back/forward.
22
- * - Publishes the live URL via `<Meta url={…} params={…}>` so descendant `<Router>`s re-render on navigation.
22
+ * - Publishes the live URL into the `Meta` context via `mergeMeta()`, so descendant `<Router>`s re-render on navigation and merge invariants hold (e.g. `root` defaults to the live URL's origin when unset).
23
23
  *
24
24
  * Exactly one `<Navigation>` per app — nested routers share this single store.
25
25
  *
@@ -29,8 +29,8 @@ export interface NavigationProps extends PossibleMeta, OptionalChildProps {}
29
29
  * @see https://shelving.cc/ui/Navigation
30
30
  */
31
31
  export function Navigation({ children, ...meta }: NavigationProps): ReactElement {
32
- const { url, root, ...merged } = requireMeta(meta);
33
- const nav = useInstance(NavigationStore, url, root);
32
+ const current = requireMeta(meta);
33
+ const nav = useInstance(NavigationStore, current.url, current.root);
34
34
  useStore(nav);
35
35
 
36
36
  useEffect(() => {
@@ -63,7 +63,7 @@ export function Navigation({ children, ...meta }: NavigationProps): ReactElement
63
63
 
64
64
  return (
65
65
  <NavigationContext value={nav}>
66
- <MetaContext value={{ url: nav.value, root, ...merged }}>{children}</MetaContext>
66
+ <MetaContext value={mergeMeta(current, { url: nav.value }, Navigation)}>{children}</MetaContext>
67
67
  </NavigationContext>
68
68
  );
69
69
  }
package/ui/util/meta.d.ts CHANGED
@@ -4,7 +4,7 @@ import type { AnyCaller } from "../../util/function.js";
4
4
  import { type PossibleLink } from "../../util/link.js";
5
5
  import type { Nullish } from "../../util/null.js";
6
6
  import { type ImmutableURI, type PossibleURI, type PossibleURIParams } from "../../util/uri.js";
7
- import { type ImmutableURL, type PossibleURL } from "../../util/url.js";
7
+ import { ImmutableURL, type PossibleURL } from "../../util/url.js";
8
8
  /**
9
9
  * Set of named meta `<meta />` tags in `{ name: content }` format.
10
10
  *
@@ -75,7 +75,7 @@ export interface Meta {
75
75
  * @see https://shelving.cc/ui/PossibleMeta
76
76
  */
77
77
  export interface PossibleMeta extends Omit<Meta, "root" | "url" | "links" | "scripts" | "modules" | "stylesheets"> {
78
- /** Base URL for the app — accepts a string or `URL`, resolved with `requireURL()`. */
78
+ /** Base URL for the app — accepts a string or `URL`, resolved with `requireURL()`. Defaults to the origin of `url` when unset. */
79
79
  readonly root?: PossibleURL | undefined;
80
80
  /**
81
81
  * New URL for the page.
@@ -116,6 +116,7 @@ export declare function joinTitles(...titles: (string | undefined)[]): string;
116
116
  *
117
117
  * - `title` is merged with `joinTitles()`.
118
118
  * - `url` is resolved to an absolute URL, e.g. `./d/e/f` + `/a/b/c` becomes `https://d.com/a/b/c/d/e/f`
119
+ * - `root` defaults to the origin of the resolved `url` (e.g. `https://x.com/`) when not set explicitly, so merged meta with a `url` always has a `root`.
119
120
  * - `stylesheets` and `links` hrefs newly set in `meta2` are absolutified against the merged `url`/`root`, so they stay correct no matter where they are later rendered.
120
121
  *
121
122
  * @param meta1 The existing fully-resolved `Meta` to merge into.
package/ui/util/meta.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { getDictionaryItems } from "../../util/dictionary.js";
2
2
  import { requireLink } from "../../util/link.js";
3
3
  import { withURIParams } from "../../util/uri.js";
4
- import { requireURL } from "../../util/url.js";
4
+ import { ImmutableURL, requireURL } from "../../util/url.js";
5
5
  /**
6
6
  * Turn a deconstructed `MetaCSP` into a `Content-Security-Policy` string.
7
7
  *
@@ -33,6 +33,7 @@ export function joinTitles(...titles) {
33
33
  *
34
34
  * - `title` is merged with `joinTitles()`.
35
35
  * - `url` is resolved to an absolute URL, e.g. `./d/e/f` + `/a/b/c` becomes `https://d.com/a/b/c/d/e/f`
36
+ * - `root` defaults to the origin of the resolved `url` (e.g. `https://x.com/`) when not set explicitly, so merged meta with a `url` always has a `root`.
36
37
  * - `stylesheets` and `links` hrefs newly set in `meta2` are absolutified against the merged `url`/`root`, so they stay correct no matter where they are later rendered.
37
38
  *
38
39
  * @param meta1 The existing fully-resolved `Meta` to merge into.
@@ -44,8 +45,10 @@ export function joinTitles(...titles) {
44
45
  */
45
46
  export function mergeMeta(meta1, meta2, caller = mergeMeta) {
46
47
  const title = joinTitles(meta2.title, meta1.title);
47
- const root = mergeMetaURL(undefined, meta1.root, meta2.root, undefined, caller);
48
- const url = mergeMetaURL(root, meta1.url, meta2.url, meta2.params, caller);
48
+ const explicitRoot = mergeMetaURL(undefined, meta1.root, meta2.root, undefined, caller);
49
+ const url = mergeMetaURL(explicitRoot, meta1.url, meta2.url, meta2.params, caller);
50
+ // A meta with a `url` must always have a `root` — only correct for apps served from the origin's `/`, so sub-path deployments must set `root` explicitly.
51
+ const root = explicitRoot ?? (url ? new ImmutableURL("/", url) : undefined);
49
52
  return {
50
53
  ...meta1,
51
54
  ...meta2,
@@ -60,4 +60,21 @@ describe("mergeMeta", () => {
60
60
  expect(meta2.links?.icon?.href).toBe("http://x.com/favicon.png");
61
61
  expect(meta2.links?.canonical?.href).toBe("http://x.com/page");
62
62
  });
63
+
64
+ test("defaults root to the origin of the url when root is unset", () => {
65
+ const meta = createMeta({ url: "http://x.com/sub/page" });
66
+ expect(meta.url?.href).toBe("http://x.com/sub/page");
67
+ expect(meta.root?.href).toBe("http://x.com/");
68
+ });
69
+
70
+ test("keeps an explicit root instead of defaulting from the url", () => {
71
+ const meta = createMeta({ root: "http://x.com/app/", url: "http://x.com/app/page" });
72
+ expect(meta.root?.href).toBe("http://x.com/app/");
73
+ });
74
+
75
+ test("leaves root unset when url is also unset", () => {
76
+ const meta = createMeta({ title: "Title" });
77
+ expect(meta.root).toBeUndefined();
78
+ expect(meta.url).toBeUndefined();
79
+ });
63
80
  });
package/ui/util/meta.ts CHANGED
@@ -4,7 +4,7 @@ import type { AnyCaller } from "../../util/function.js";
4
4
  import { type PossibleLink, requireLink } from "../../util/link.js";
5
5
  import type { Nullish } from "../../util/null.js";
6
6
  import { type ImmutableURI, type PossibleURI, type PossibleURIParams, withURIParams } from "../../util/uri.js";
7
- import { type ImmutableURL, type PossibleURL, requireURL } from "../../util/url.js";
7
+ import { ImmutableURL, type PossibleURL, requireURL } from "../../util/url.js";
8
8
 
9
9
  /**
10
10
  * Set of named meta `<meta />` tags in `{ name: content }` format.
@@ -85,7 +85,7 @@ export interface Meta {
85
85
  * @see https://shelving.cc/ui/PossibleMeta
86
86
  */
87
87
  export interface PossibleMeta extends Omit<Meta, "root" | "url" | "links" | "scripts" | "modules" | "stylesheets"> {
88
- /** Base URL for the app — accepts a string or `URL`, resolved with `requireURL()`. */
88
+ /** Base URL for the app — accepts a string or `URL`, resolved with `requireURL()`. Defaults to the origin of `url` when unset. */
89
89
  readonly root?: PossibleURL | undefined;
90
90
 
91
91
  /**
@@ -139,6 +139,7 @@ export function joinTitles(...titles: (string | undefined)[]): string {
139
139
  *
140
140
  * - `title` is merged with `joinTitles()`.
141
141
  * - `url` is resolved to an absolute URL, e.g. `./d/e/f` + `/a/b/c` becomes `https://d.com/a/b/c/d/e/f`
142
+ * - `root` defaults to the origin of the resolved `url` (e.g. `https://x.com/`) when not set explicitly, so merged meta with a `url` always has a `root`.
142
143
  * - `stylesheets` and `links` hrefs newly set in `meta2` are absolutified against the merged `url`/`root`, so they stay correct no matter where they are later rendered.
143
144
  *
144
145
  * @param meta1 The existing fully-resolved `Meta` to merge into.
@@ -151,8 +152,11 @@ export function joinTitles(...titles: (string | undefined)[]): string {
151
152
  export function mergeMeta(meta1: Meta, meta2: PossibleMeta, caller: AnyCaller = mergeMeta): Meta {
152
153
  const title = joinTitles(meta2.title, meta1.title);
153
154
 
154
- const root = mergeMetaURL(undefined, meta1.root, meta2.root, undefined, caller);
155
- const url = mergeMetaURL(root, meta1.url, meta2.url, meta2.params, caller);
155
+ const explicitRoot = mergeMetaURL(undefined, meta1.root, meta2.root, undefined, caller);
156
+ const url = mergeMetaURL(explicitRoot, meta1.url, meta2.url, meta2.params, caller);
157
+
158
+ // A meta with a `url` must always have a `root` — only correct for apps served from the origin's `/`, so sub-path deployments must set `root` explicitly.
159
+ const root = explicitRoot ?? (url ? new ImmutableURL("/", url) : undefined);
156
160
 
157
161
  return {
158
162
  ...meta1,