@colixsystems/widget-sdk 0.101.0 → 0.102.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.
package/README.md CHANGED
@@ -24,6 +24,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
24
24
  | **CORE** | `useNavigation()` | `{ goTo, goBack, push, replace, back, currentRoute, openLink }` | `ctx.navigation` — no scope (`openLink` for a link of unknown shape; a known external URL can also use the `Linking` primitive) |
25
25
  | **CORE** | `useRouteParams()` | `{ [paramKey]: value }` | `ctx.navigation.currentRoute.params` — no scope. The nav params the previous page passed via `goTo(pageId, params)`; the flat accessor for master→detail (read `recordId` on a detail page). Empty object when none. |
26
26
  | **CORE** | `usePageContext()` | `{ params, records }` | `ctx.pageContext` — no scope. The page's DECLARED parameters, resolved once by the host: `params` are coerced to their declared types, `records` holds the row already fetched for each `record` param (read it instead of fetching again). Both empty when the page declares none. |
27
+ | **CORE** | `useWidgetRoute(initial)` | `[state, setState]` | `ctx.widgetRoute` — no scope. Where YOUR WIDGET is, persisted by the host so it survives a reload and travels in a shared link (the `w_<instanceId>` query key on web, the screen's route params natively): the folder a browser has opened, a wizard step, a selected tab, a list's sort and search. `useState` semantics over an object — writes MERGE, `null` clears a key back to its `initial`, and `initial` is read once. Values are scalars or flat arrays of scalars, size- and length-capped; anything else is not stored. NOT history (Back still leaves the page, on both platforms). Degrades to component state on the Studio canvas. |
27
28
  | **CORE** | `useWidgetEvent(name)` | `(payload?) => void` | `ctx.events.emit` — no scope. The hook IS the emitter: `const emitSlot = useWidgetEvent("slotChosen")`, then `emitSlot(payload)`. Never destructure the result — there is no `emit` member. |
28
29
  | **CORE** | `useWidgetInput(inputName)` | the published payload, or `undefined` | `ctx.inputs` — no scope. Reads a value ANOTHER widget on the same page published with `useWidgetEvent`. Declare the input in `manifest.inputs`; the page author wires it to one sibling's declared event. The channel retains the last payload, so a widget that mounts later still reads it. `undefined` while unwired or before the first publish — always render a sensible default. Page-scoped and ephemeral: use `useRouteParams()` for state that must survive navigation, the datastore for state that must persist. |
29
30
  | **CORE** | `useChildRenderer()` | `{ renderNode(node) }` | `ctx.renderer` — no scope (prefer the `WidgetTree` component) |
@@ -837,6 +838,10 @@ The "split-implementation + vetted package list" pivot.
837
838
  - **`useDatastoreRecord(tableId, recordId)` is wired.** Returns `{ data, loading, error, refetch }` for a single record fetched through the host's `records(table).get(id)`. Sister to `useDatastoreQuery`; mirrors its ref discipline so `refetch` stays a stable callback identity. A 404 surfaces as `DatastoreError.code === "NOT_FOUND"`. Additive.
838
839
  - **`useAsset(fileId)` is wired** + new `WidgetContext.assets` slice. Returns `{ url, file, loading, error, refetch }` — the `url` is an absolute URL the widget can drop straight into `<Image source>`. Backed by a new `files.get(fileId)` host facade (web: `widgetHostFiles` through `api/client`; native: `hostFiles` in the export's `widgetHost.js`). Additive.
839
840
 
841
+ ### What's new in 0.102.0
842
+
843
+ - **`useWidgetRoute(initial)` persists your widget's own internal position.** A widget's internal navigation lived in bare `useState`, so it was neither linkable nor durable: a reload dropped the visitor back at the opening view. Use the hook exactly like `useState` with an object — `const [view, setView] = useWidgetRoute({ path: [], sort: 'name' })` — and the host persists the bag: the view survives a reload and a copied link opens the widget where the sender was. Writes MERGE, `null` clears a key back to the `initial` you declared (which is what keeps the address clean), and `initial` is read once like `useState`'s argument. Values are scalars or flat arrays of scalars — a path, a list of visited steps — and are size- and length-capped; nothing else is stored. Scoped to the PLACEMENT, so the same widget placed twice on a page keeps two independent views. Three rules worth reading twice: it is **not history** (Back still leaves the page on both platforms, because native has no equivalent stack to mirror an in-widget one onto), it is **opt-in per key** (transient UI — an open dropdown, a half-typed form — stays in ordinary `useState`), and for a **text input** you should keep the box in `useState` and push it in on a short debounce rather than rewriting the address once per keystroke. Degrades to plain component state on the Studio canvas, so it is always safe to call. Additive (v0.102.0).
844
+
840
845
  ### What's new in 0.11.0
841
846
 
842
847
  - **`useNavigation()` is wired.** Returns the host-provided navigation surface `{ goTo, goBack, push, replace, back, currentRoute, openLink }` for internal page-to-page navigation. Missing methods degrade to no-ops on the Studio canvas preview. Additive.
@@ -915,7 +920,7 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
915
920
 
916
921
  - `defineWidget({ manifest, component })` — validates the manifest and produces a widget module the host can register.
917
922
  - `validateManifest(m)` / `validatePropertySchema(s)` / `validateProps(schema, props)` — shape validation; no third-party deps.
918
- - `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreSchema`, `useDatastoreMutation`, `useDirectory`, `useUsers`, `useGroups`, `useRecordPermissions`, `useAsset`, `useWidgetEvent`, `useWidgetInput`, `usePayments`, `useSendNotification`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `useRouteParams`, `usePageContext`, `useChildRenderer`, `useClipboard`, `useToast` — hooks that read from the host-provided `WidgetContext` (or, for `useClipboard`, the platform clipboard API directly). `useDirectory(query?)` returns `{ users, loading, error, refetch }` (each user `{ id, name, role }`) and requires the `directory.read:users` scope. `useUsers(query?)` returns `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }` and requires `users.read:*` (mutations also need `users.write:*`); rejections are a `DirectoryError`. `useGroups(query?)` returns `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` and requires `groups.read:*` (mutations also need `groups.write:*`). `usePayments()` returns `{ requestPayment, getPayment }` and requires the `payments.charge:appUser` scope; `requestPayment(...)` rejects with a `PaymentError` carrying `code`, the server's user-safe `message`, and `retryable` (`false` = this charge cannot succeed until the workspace, manifest, or amount changes — show the message, not a retry). `useSendNotification()` returns `{ send, sending, error }` and requires the `notifications.send:appUser` scope; `send({ recipient_user_id, title, body, link?, payload? })` notifies one app user in the same workspace (cross-workspace `recipient_user_id` is rejected), must be called from an event handler rather than render, and rejects with a `NotificationError`. `useUser()` returns the active end-user identity `{ id, email, displayName, roles, groupIds }` (camelCase — the host-built context object, not a wire payload; `id` is `null` for anonymous / preview). `useNavigation()` returns `{ goTo, goBack, push, replace, back, currentRoute, openLink }` for internal page navigation; `openLink(link)` follows an author- or data-supplied link of unknown shape through the host's shared resolver (in-app page → internal route, off-app http(s) → opened outside, unsafe → refused) and is the safe choice for any value your code did not construct, while `Linking.openURL(url)` is for an external URL you built yourself. `useRouteParams()` returns the current route's params object (`currentRoute.params`) — the flat master→detail accessor; read a param off it (e.g. `recordId`), never call it. `useDatastoreRecord(tableId, recordId)` returns `{ data, loading, error, refetch }` for a single record (data is one row or null). `useDatastoreSchema(tableId)` returns `{ schema, loading, error, refetch }` where `schema` is `{ id, name, columns: [{ id, name, data_type, required, relation_type, target_table_id, is_identification }] }` (structure only, no row data; snake_case verbatim) — use it to resolve a stored `columnId` to its column type at runtime; requires the `datastore.read:<table>` scope. `useAsset(fileId)` returns `{ url, file, loading, error, refetch }` — the `url` is an absolute URL composed against the host's API base. `useChildRenderer()` returns `{ renderNode(node) }` — container widgets call it to render arbitrary child page-tree nodes (prefer the `WidgetTree` component for the common case). `useWidgetInput(inputName)` returns the latest payload a sibling widget published on the event the page author wired to this widget's declared `inputs` entry (`undefined` when unwired or not yet published).
923
+ - `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreSchema`, `useDatastoreMutation`, `useDirectory`, `useUsers`, `useGroups`, `useRecordPermissions`, `useAsset`, `useWidgetEvent`, `useWidgetInput`, `usePayments`, `useSendNotification`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `useRouteParams`, `usePageContext`, `useWidgetRoute`, `useChildRenderer`, `useClipboard`, `useToast` — hooks that read from the host-provided `WidgetContext` (or, for `useClipboard`, the platform clipboard API directly). `useDirectory(query?)` returns `{ users, loading, error, refetch }` (each user `{ id, name, role }`) and requires the `directory.read:users` scope. `useUsers(query?)` returns `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }` and requires `users.read:*` (mutations also need `users.write:*`); rejections are a `DirectoryError`. `useGroups(query?)` returns `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` and requires `groups.read:*` (mutations also need `groups.write:*`). `usePayments()` returns `{ requestPayment, getPayment }` and requires the `payments.charge:appUser` scope; `requestPayment(...)` rejects with a `PaymentError` carrying `code`, the server's user-safe `message`, and `retryable` (`false` = this charge cannot succeed until the workspace, manifest, or amount changes — show the message, not a retry). `useSendNotification()` returns `{ send, sending, error }` and requires the `notifications.send:appUser` scope; `send({ recipient_user_id, title, body, link?, payload? })` notifies one app user in the same workspace (cross-workspace `recipient_user_id` is rejected), must be called from an event handler rather than render, and rejects with a `NotificationError`. `useUser()` returns the active end-user identity `{ id, email, displayName, roles, groupIds }` (camelCase — the host-built context object, not a wire payload; `id` is `null` for anonymous / preview). `useNavigation()` returns `{ goTo, goBack, push, replace, back, currentRoute, openLink }` for internal page navigation; `openLink(link)` follows an author- or data-supplied link of unknown shape through the host's shared resolver (in-app page → internal route, off-app http(s) → opened outside, unsafe → refused) and is the safe choice for any value your code did not construct, while `Linking.openURL(url)` is for an external URL you built yourself. `useRouteParams()` returns the current route's params object (`currentRoute.params`) — the flat master→detail accessor; read a param off it (e.g. `recordId`), never call it. `useDatastoreRecord(tableId, recordId)` returns `{ data, loading, error, refetch }` for a single record (data is one row or null). `useDatastoreSchema(tableId)` returns `{ schema, loading, error, refetch }` where `schema` is `{ id, name, columns: [{ id, name, data_type, required, relation_type, target_table_id, is_identification }] }` (structure only, no row data; snake_case verbatim) — use it to resolve a stored `columnId` to its column type at runtime; requires the `datastore.read:<table>` scope. `useAsset(fileId)` returns `{ url, file, loading, error, refetch }` — the `url` is an absolute URL composed against the host's API base. `useChildRenderer()` returns `{ renderNode(node) }` — container widgets call it to render arbitrary child page-tree nodes (prefer the `WidgetTree` component for the common case). `useWidgetInput(inputName)` returns the latest payload a sibling widget published on the event the page author wired to this widget's declared `inputs` entry (`undefined` when unwired or not yet published).
919
924
  - `WidgetTree({ node })` — component that renders an author-authored child node through the host's renderer; used by Tabs / Card / custom containers to host arbitrary child widgets.
920
925
  - `Text`, `View`, `Pressable`, `Image`, `ScrollView`, `TextInput`, `FlatList`, `SectionList`, `ActivityIndicator`, `Switch`, `StyleSheet`, `Linking`, `Icon`, `DateTimePicker` — re-exported from `react-native` (the RN primitives) or implemented in the SDK (`Icon` wraps `lucide-react-native`; `DateTimePicker` wraps `@react-native-community/datetimepicker` on native and renders `<input type="date|time|datetime-local">` directly on web because the RN library has no react-native-web mapping). The web build aliases `react-native` to `react-native-web` so the RN-re-exported primitives render in the browser without any per-platform code; the exported Expo app's Metro bundler resolves the real `react-native` library. `Linking` is a static API (`Linking.openURL(url)`) — use it for external URLs, and use `useNavigation().goTo(pageId)` for internal page navigation. See https://reactnative.dev/docs/ for per-component props.
921
926
  - `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
package/dist/contract.cjs CHANGED
@@ -688,6 +688,20 @@ const HOOKS = [
688
688
  requiredContextSlice: ["pageContext"],
689
689
  scopes: null,
690
690
  },
691
+ {
692
+ name: "useWidgetRoute",
693
+ signature: "useWidgetRoute(initial)",
694
+ returnShape: {
695
+ "[0]":
696
+ "the widget INSTANCE's own internal position, merged over the initial object you declared — the folder a browser is in, a wizard step, a list's sort/search. Survives a reload and travels in a shared link.",
697
+ "[1]":
698
+ "setState(patchOrUpdater) — merges like a class setState. A null value clears that key back to its initial. Scalars only (string, finite number, boolean); anything else is dropped.",
699
+ },
700
+ description:
701
+ "The widget's OWN internal position, persisted by the host so it survives a reload and travels in a shared link — the folder a file browser is in, a wizard step, a list's sort and search. Use it like useState with an object: writes MERGE, null clears a key back to its initial, and initial is read once. Values are scalars or flat arrays of scalars (a path, a list of visited steps). Only for state a visitor would link to or expect to survive a reload; transient UI stays in plain useState. It is NOT history — Back still leaves the page, on both platforms.",
702
+ requiredContextSlice: ["widgetRoute"],
703
+ scopes: null,
704
+ },
691
705
  {
692
706
  name: "useDatastoreRecord",
693
707
  signature: "useDatastoreRecord(tableId, recordId)",
@@ -1938,6 +1952,15 @@ const WIDGET_CONTEXT_SHAPE = {
1938
1952
  required: true,
1939
1953
  fields: { params: "object", records: "object" },
1940
1954
  },
1955
+ widgetRoute: {
1956
+ description:
1957
+ "sc-5717 — the widget INSTANCE's own persisted internal position. " +
1958
+ "{ params: { <key>: scalar }, set(bag) }. " +
1959
+ "params is what the host read back for THIS placement (the w_<instanceId> query key on web, the screen's route params on native); set writes the changed-from-initial bag back. " +
1960
+ "Absent on a host with nowhere to persist (the Studio canvas), where useWidgetRoute() degrades to component state. Backs useWidgetRoute().",
1961
+ required: false,
1962
+ fields: { params: "object", set: "function" },
1963
+ },
1941
1964
  datastore: {
1942
1965
  description:
1943
1966
  "Injected @colixsystems/datastore-client instance. " +
@@ -2565,6 +2588,21 @@ function normaliseCurrencyCode(value) {
2565
2588
  * A non-finite amount renders as zero rather than "NaN": a price whose data has
2566
2589
  * not loaded should read as an amount, never as a broken string in a checkout.
2567
2590
  */
2591
+ /**
2592
+ * sc-5717 — value equality for a stored widget-route value: is this key still
2593
+ * at the default its widget declared?
2594
+ *
2595
+ * Structural for arrays, because two equal arrays are never Object.is-equal —
2596
+ * identity alone would report an array key as permanently moved-from-default,
2597
+ * so its key could never be cleared from the address again. Lives here rather
2598
+ * than beside the codec so hooks.js and widget-route.js share ONE rule.
2599
+ */
2600
+ function sameWidgetRouteValue(a, b) {
2601
+ if (Array.isArray(a) && Array.isArray(b)) {
2602
+ return a.length === b.length && a.every((item, i) => Object.is(item, b[i]));
2603
+ }
2604
+ return Object.is(a, b);
2605
+ }
2568
2606
  function formatMoneyIn(currency, minorUnits) {
2569
2607
  const code = normaliseCurrencyCode(currency);
2570
2608
  const fmt = CURRENCY_FORMATS[code] || {
@@ -3221,7 +3259,7 @@ const CONTRACT = deepFreeze({
3221
3259
  // are compressed to WebP by default; `compress: false` stores the file
3222
3260
  // byte-for-byte. Existing callers are unaffected — the field is only sent
3223
3261
  // when the opt-out is chosen.
3224
- version: "1.75.0",
3262
+ version: "1.76.0",
3225
3263
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
3226
3264
  hooks: HOOKS,
3227
3265
  primitives: PRIMITIVES,
@@ -3521,6 +3559,7 @@ module.exports = {
3521
3559
  normaliseComponentGradient,
3522
3560
  formatMoneyIn,
3523
3561
  normaliseCurrencyCode,
3562
+ sameWidgetRouteValue,
3524
3563
  widgetTranslationPrefix,
3525
3564
  widgetTranslationKey,
3526
3565
  sharedTranslationPrefix,
package/dist/contract.js CHANGED
@@ -688,6 +688,20 @@ const HOOKS = [
688
688
  requiredContextSlice: ["pageContext"],
689
689
  scopes: null,
690
690
  },
691
+ {
692
+ name: "useWidgetRoute",
693
+ signature: "useWidgetRoute(initial)",
694
+ returnShape: {
695
+ "[0]":
696
+ "the widget INSTANCE's own internal position, merged over the initial object you declared — the folder a browser is in, a wizard step, a list's sort/search. Survives a reload and travels in a shared link.",
697
+ "[1]":
698
+ "setState(patchOrUpdater) — merges like a class setState. A null value clears that key back to its initial. Scalars only (string, finite number, boolean); anything else is dropped.",
699
+ },
700
+ description:
701
+ "The widget's OWN internal position, persisted by the host so it survives a reload and travels in a shared link — the folder a file browser is in, a wizard step, a list's sort and search. Use it like useState with an object: writes MERGE, null clears a key back to its initial, and initial is read once. Values are scalars or flat arrays of scalars (a path, a list of visited steps). Only for state a visitor would link to or expect to survive a reload; transient UI stays in plain useState. It is NOT history — Back still leaves the page, on both platforms.",
702
+ requiredContextSlice: ["widgetRoute"],
703
+ scopes: null,
704
+ },
691
705
  {
692
706
  name: "useDatastoreRecord",
693
707
  signature: "useDatastoreRecord(tableId, recordId)",
@@ -1938,6 +1952,15 @@ const WIDGET_CONTEXT_SHAPE = {
1938
1952
  required: true,
1939
1953
  fields: { params: "object", records: "object" },
1940
1954
  },
1955
+ widgetRoute: {
1956
+ description:
1957
+ "sc-5717 — the widget INSTANCE's own persisted internal position. " +
1958
+ "{ params: { <key>: scalar }, set(bag) }. " +
1959
+ "params is what the host read back for THIS placement (the w_<instanceId> query key on web, the screen's route params on native); set writes the changed-from-initial bag back. " +
1960
+ "Absent on a host with nowhere to persist (the Studio canvas), where useWidgetRoute() degrades to component state. Backs useWidgetRoute().",
1961
+ required: false,
1962
+ fields: { params: "object", set: "function" },
1963
+ },
1941
1964
  datastore: {
1942
1965
  description:
1943
1966
  "Injected @colixsystems/datastore-client instance. " +
@@ -2565,6 +2588,21 @@ function normaliseCurrencyCode(value) {
2565
2588
  * A non-finite amount renders as zero rather than "NaN": a price whose data has
2566
2589
  * not loaded should read as an amount, never as a broken string in a checkout.
2567
2590
  */
2591
+ /**
2592
+ * sc-5717 — value equality for a stored widget-route value: is this key still
2593
+ * at the default its widget declared?
2594
+ *
2595
+ * Structural for arrays, because two equal arrays are never Object.is-equal —
2596
+ * identity alone would report an array key as permanently moved-from-default,
2597
+ * so its key could never be cleared from the address again. Lives here rather
2598
+ * than beside the codec so hooks.js and widget-route.js share ONE rule.
2599
+ */
2600
+ function sameWidgetRouteValue(a, b) {
2601
+ if (Array.isArray(a) && Array.isArray(b)) {
2602
+ return a.length === b.length && a.every((item, i) => Object.is(item, b[i]));
2603
+ }
2604
+ return Object.is(a, b);
2605
+ }
2568
2606
  function formatMoneyIn(currency, minorUnits) {
2569
2607
  const code = normaliseCurrencyCode(currency);
2570
2608
  const fmt = CURRENCY_FORMATS[code] || {
@@ -3221,7 +3259,7 @@ const CONTRACT = deepFreeze({
3221
3259
  // are compressed to WebP by default; `compress: false` stores the file
3222
3260
  // byte-for-byte. Existing callers are unaffected — the field is only sent
3223
3261
  // when the opt-out is chosen.
3224
- version: "1.75.0",
3262
+ version: "1.76.0",
3225
3263
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
3226
3264
  hooks: HOOKS,
3227
3265
  primitives: PRIMITIVES,
@@ -3521,6 +3559,7 @@ export {
3521
3559
  normaliseComponentGradient,
3522
3560
  formatMoneyIn,
3523
3561
  normaliseCurrencyCode,
3562
+ sameWidgetRouteValue,
3524
3563
  widgetTranslationPrefix,
3525
3564
  widgetTranslationKey,
3526
3565
  sharedTranslationPrefix,
package/dist/hooks.js CHANGED
@@ -43,6 +43,9 @@ import {
43
43
  isSharedTranslationKey,
44
44
  formatMoneyIn,
45
45
  normaliseCurrencyCode,
46
+ // sc-5717 — "is this key still at its declared default", shared with the
47
+ // host codec so the hook and the encoder can never disagree about it.
48
+ sameWidgetRouteValue,
46
49
  } from "./contract.js";
47
50
 
48
51
  /** @internal — host-injected context value of shape WidgetContext (see index.d.ts). */
@@ -479,6 +482,103 @@ export function usePageContext() {
479
482
  };
480
483
  }
481
484
 
485
+ const EMPTY_WIDGET_ROUTE = Object.freeze({ params: EMPTY_PARAMS, set: null });
486
+
487
+ /**
488
+ * sc-5717 — `[state, setState]` for the widget's OWN internal position, which
489
+ * the host persists so it survives a reload and travels in a shared link.
490
+ *
491
+ * Use it exactly like `useState` with an object, for the state that answers
492
+ * "where is this widget right now": the folder a browser is in, a wizard's
493
+ * step, a list's sort and search.
494
+ *
495
+ * const [view, setView] = useWidgetRoute({ folder: null, sort: "name" });
496
+ * setView({ folder: row.id }); // merges, like a class setState
497
+ * setView((v) => ({ sort: flip(v.sort) }));
498
+ *
499
+ * Semantics worth knowing:
500
+ *
501
+ * - **Merging, not replacing.** A partial write leaves the other keys alone.
502
+ * - **Values are scalars** — string, finite number, boolean. `null` clears a key
503
+ * back to the initial value you declared, which is also what keeps the URL
504
+ * clean rather than carrying `{"folder":null}` forever.
505
+ * - **`initial` is read once**, like `useState`'s initial argument. Re-rendering
506
+ * with a different literal does not reset anything.
507
+ * - **It is not history.** Persisting the position is not the same as making
508
+ * Back step through it: pressing Back leaves the page on both platforms, the
509
+ * same way it did before. Native has no URL to step through and no
510
+ * host-neutral way to intercept its back gesture per widget, so an
511
+ * in-widget back stack would behave one way on the web Player and another in
512
+ * the exported app — the divergence CLAUDE.md §8 exists to prevent.
513
+ * - **Opt in per key.** Only put state here that a visitor would reasonably
514
+ * link to or expect to survive a reload. Transient UI — an open dropdown, a
515
+ * half-typed form — belongs in ordinary `useState`.
516
+ *
517
+ * On a host with nowhere to persist (the Studio canvas preview) it degrades to
518
+ * plain component state, so a widget behaves the same in every surface.
519
+ */
520
+ export function useWidgetRoute(initial) {
521
+ const ctx = useWidgetContextOrThrow("useWidgetRoute");
522
+ const slice = ctx.widgetRoute || EMPTY_WIDGET_ROUTE;
523
+ const stored = slice.params || EMPTY_PARAMS;
524
+
525
+ // `initial` is the DECLARED default, captured once — a caller passing an
526
+ // object literal re-creates it every render, and re-resolving from the newest
527
+ // one would let an unrelated re-render silently redefine "cleared".
528
+ const initialRef = useRef(null);
529
+ if (initialRef.current === null) {
530
+ initialRef.current =
531
+ initial && typeof initial === "object" && !Array.isArray(initial)
532
+ ? { ...initial }
533
+ : {};
534
+ }
535
+
536
+ // The canvas has no host store; fall back to component state so the widget
537
+ // still works, just without persistence.
538
+ const [local, setLocal] = useState(initialRef.current);
539
+ const persisted = typeof slice.set === "function";
540
+
541
+ const state = useMemo(
542
+ () => (persisted ? { ...initialRef.current, ...stored } : local),
543
+ [persisted, stored, local],
544
+ );
545
+
546
+ // `state` is read inside, so the callback must see the current one; a ref
547
+ // keeps `setState` referentially stable for widgets that pass it to a memoised
548
+ // child or list an effect dependency on it.
549
+ const stateRef = useRef(state);
550
+ stateRef.current = state;
551
+ const setRef = useRef(slice.set);
552
+ setRef.current = slice.set;
553
+
554
+ const setState = useCallback((patch) => {
555
+ const current = stateRef.current;
556
+ const next = typeof patch === "function" ? patch(current) : patch;
557
+ if (!next || typeof next !== "object" || Array.isArray(next)) return;
558
+ const merged = { ...current, ...next };
559
+ const write = setRef.current;
560
+ if (typeof write !== "function") {
561
+ setLocal(merged);
562
+ return;
563
+ }
564
+ // Only the DIFFERENCE from the declared initial is handed to the host. A key
565
+ // sitting at its default is restored from `initial` anyway, so storing it
566
+ // would spend URL budget to say nothing — and returning a widget to its
567
+ // opening view leaves a clean address rather than a bag full of defaults.
568
+ const defaults = initialRef.current;
569
+ const changed = {};
570
+ for (const key of Object.keys(merged)) {
571
+ if (key in defaults && sameWidgetRouteValue(merged[key], defaults[key])) {
572
+ continue;
573
+ }
574
+ changed[key] = merged[key];
575
+ }
576
+ write(changed);
577
+ }, []);
578
+
579
+ return [state, setState];
580
+ }
581
+
482
582
  /**
483
583
  * Returns { t, locale }. `t(key, fallback)` resolves `{{t:key}}` against
484
584
  * the host's translation table and falls back to `fallback ?? key` when
package/dist/host.d.ts CHANGED
@@ -182,3 +182,43 @@ export interface FooterTokens {
182
182
  * renders exactly as it did before the block existed.
183
183
  */
184
184
  export function resolveFooterTokens(theme: unknown): FooterTokens;
185
+
186
+ /** A value a widget may persist: a scalar, or a flat array of them. */
187
+ export type WidgetRouteScalar = string | number | boolean;
188
+ export type WidgetRouteValue = WidgetRouteScalar | WidgetRouteScalar[];
189
+
190
+ /** How many entries one array value may carry. */
191
+ export const WIDGET_ROUTE_MAX_ITEMS: number;
192
+
193
+ /** Value equality for "is this key still at its declared default" — structural
194
+ * for arrays, so an array key can be cleared from the address. */
195
+ export function sameWidgetRouteValue(a: unknown, b: unknown): boolean;
196
+
197
+ /** Query-string / route-param key prefix for a widget's stored position. */
198
+ export const WIDGET_ROUTE_PREFIX: string;
199
+
200
+ /** Cap on ONE instance's encoded bag, so a single widget cannot spend the
201
+ * whole URL budget. An over-cap write is dropped, never truncated. */
202
+ export const WIDGET_ROUTE_MAX_CHARS: number;
203
+
204
+ /** The key one placed widget instance stores its route bag under. */
205
+ export function widgetRouteKey(instanceId: string): string;
206
+
207
+ /** Whether a raw query-string key belongs to a widget rather than the page. */
208
+ export function isWidgetRouteKey(key: unknown): boolean;
209
+
210
+ /** Encode a bag for the address. Returns "" when there is nothing to store or
211
+ * the bag exceeds `WIDGET_ROUTE_MAX_CHARS` — the caller removes the key. */
212
+ export function encodeWidgetRoute(
213
+ bag: Record<string, unknown> | null | undefined,
214
+ ): string;
215
+
216
+ /** Decode what the address carried. Total: any malformed input yields `{}`. */
217
+ export function decodeWidgetRoute(
218
+ raw: unknown,
219
+ ): Record<string, WidgetRouteValue>;
220
+
221
+ /** Every widget's stored bag out of a raw query-string map, keyed by instance id. */
222
+ export function collectWidgetRoutes(
223
+ rawParams: Record<string, string> | null | undefined,
224
+ ): Record<string, Record<string, WidgetRouteValue>>;
package/dist/host.js CHANGED
@@ -55,3 +55,21 @@ export {
55
55
  quickBarCap,
56
56
  resolveFooterTokens,
57
57
  } from "./navigation.js";
58
+
59
+ // sc-5717: the codec behind `useWidgetRoute()` — how a widget's own internal
60
+ // position is written into the address the host carries (the query string on
61
+ // web, the screen's route params on native). Host-only by necessity as well as
62
+ // by taste: a widget may not touch the URL at all, so the encoding cannot live
63
+ // on the author side. Both hosts import THIS module, so a widget's stored
64
+ // position can never mean one thing in the Player and another in the export.
65
+ export {
66
+ WIDGET_ROUTE_PREFIX,
67
+ WIDGET_ROUTE_MAX_CHARS,
68
+ WIDGET_ROUTE_MAX_ITEMS,
69
+ widgetRouteKey,
70
+ isWidgetRouteKey,
71
+ encodeWidgetRoute,
72
+ decodeWidgetRoute,
73
+ collectWidgetRoutes,
74
+ sameWidgetRouteValue,
75
+ } from "./widget-route.js";
package/dist/index.d.ts CHANGED
@@ -1278,6 +1278,28 @@ export function usePageContext(): {
1278
1278
  records: Record<string, Record<string, unknown> | null>;
1279
1279
  };
1280
1280
 
1281
+ /** A value `useWidgetRoute` can persist: a scalar, or a flat array of them. */
1282
+ export type WidgetRouteScalar = string | number | boolean;
1283
+ export type WidgetRouteValue = WidgetRouteScalar | WidgetRouteScalar[];
1284
+
1285
+ /**
1286
+ * sc-5717 — `[state, setState]` for the widget's OWN internal position, which
1287
+ * the host persists so it survives a reload and travels in a shared link: the
1288
+ * folder a browser is in, a wizard's step, a list's sort and search.
1289
+ *
1290
+ * Use it like `useState` with an object. Writes MERGE, `null` clears a key back
1291
+ * to the value you declared in `initial`, and `initial` itself is read once.
1292
+ * Only put state here that a visitor would reasonably link to or expect to
1293
+ * survive a reload — transient UI belongs in ordinary `useState`.
1294
+ *
1295
+ * It is not history: pressing Back leaves the page, on both platforms.
1296
+ */
1297
+ export function useWidgetRoute<
1298
+ T extends Record<string, WidgetRouteValue | null>,
1299
+ >(
1300
+ initial: T,
1301
+ ): [T, (patch: Partial<T> | ((current: T) => Partial<T>)) => void];
1302
+
1281
1303
  /**
1282
1304
  * Static API for external URLs. `openURL(url)` opens a URL with the OS
1283
1305
  * handler (web: react-native-web maps to `window.open` / `location.href`;
package/dist/index.js CHANGED
@@ -55,6 +55,7 @@ export {
55
55
  useNavigation,
56
56
  useRouteParams,
57
57
  usePageContext,
58
+ useWidgetRoute,
58
59
  useChildRenderer,
59
60
  useRefresh,
60
61
  useSectionEmpty,
@@ -55,6 +55,7 @@ export {
55
55
  useNavigation,
56
56
  useRouteParams,
57
57
  usePageContext,
58
+ useWidgetRoute,
58
59
  useChildRenderer,
59
60
  useRefresh,
60
61
  useSectionEmpty,
@@ -0,0 +1,151 @@
1
+ // sc-5717 — the codec for a widget's own internal navigation state.
2
+ //
3
+ // A widget's internal position (the folder a file browser is in, a list's sort
4
+ // and search) lived in bare `useState`, so it was neither linkable nor durable:
5
+ // a reload dropped the visitor back at the widget's initial view. `useWidgetRoute`
6
+ // gives that state a home the host persists — the URL on web, the screen's route
7
+ // params on native.
8
+ //
9
+ // Everything here is HOST plumbing. A widget never sees a key or an encoded
10
+ // string: it reads and writes a plain object, and the host decides how that
11
+ // object is carried. Widgets cannot touch the URL at all (`window.location` is
12
+ // in CONTRACT.bannedApis), which is exactly why the codec lives on this side.
13
+ //
14
+ // One implementation for both hosts. The web Player imports it from
15
+ // `@colixsystems/widget-sdk/host`; the generated Expo app imports the SAME
16
+ // module from the SAME entry point, so there is no mirrored copy to drift
17
+ // (CLAUDE.md §3/§8).
18
+
19
+ /** Query-string / route-param key prefix. */
20
+ export const WIDGET_ROUTE_PREFIX = "w_";
21
+
22
+ /**
23
+ * Cap on ONE instance's encoded bag.
24
+ *
25
+ * Browsers stop being reliable somewhere around 2000 characters of URL, and a
26
+ * page may hold several widgets that each store state. Capping per instance
27
+ * keeps one widget from spending the whole budget; an over-cap write is dropped
28
+ * rather than truncated, because half a JSON document decodes to nothing and
29
+ * would silently reset the widget instead of just failing to persist.
30
+ */
31
+ export const WIDGET_ROUTE_MAX_CHARS = 512;
32
+
33
+ /** The key one placed widget instance stores its route bag under. */
34
+ export function widgetRouteKey(instanceId) {
35
+ return `${WIDGET_ROUTE_PREFIX}${instanceId}`;
36
+ }
37
+
38
+ /** Whether a raw query-string key belongs to a widget rather than the page. */
39
+ export function isWidgetRouteKey(key) {
40
+ return typeof key === "string" && key.startsWith(WIDGET_ROUTE_PREFIX);
41
+ }
42
+
43
+ /**
44
+ * How many entries one array value may carry. Paths and step lists are short;
45
+ * the length cap rejects an abusive value before the character cap has to.
46
+ */
47
+ export const WIDGET_ROUTE_MAX_ITEMS = 64;
48
+
49
+ function isScalar(value) {
50
+ const type = typeof value;
51
+ if (type === "string" || type === "boolean") return true;
52
+ return type === "number" && Number.isFinite(value);
53
+ }
54
+
55
+ /**
56
+ * A scalar, or a flat array of them.
57
+ *
58
+ * Not a general serializer on purpose. The bag round-trips through a URL a
59
+ * visitor can edit, so the shape is bounded to what the host can validate
60
+ * cheaply and completely. Arrays earn their place because the common
61
+ * "where am I" state IS a path — a file browser's open folders, a wizard's
62
+ * visited steps — and modelling that as a scalar would push widgets into
63
+ * encoding their own delimited strings, which is the codec this module exists
64
+ * to be.
65
+ */
66
+ function isStorableValue(value) {
67
+ if (Array.isArray(value)) {
68
+ return value.length <= WIDGET_ROUTE_MAX_ITEMS && value.every(isScalar);
69
+ }
70
+ return isScalar(value);
71
+ }
72
+
73
+ // Value equality for "is this key still at its declared default" lives in
74
+ // contract.js, so the hook and this codec share one rule; re-exported here so a
75
+ // host has the whole route surface from one import.
76
+ export { sameWidgetRouteValue } from "./contract.js";
77
+
78
+ /**
79
+ * Encode a widget's route bag for the URL.
80
+ *
81
+ * `null` / `undefined` values are DROPPED rather than stored. That is what makes
82
+ * "back to the initial view" produce a clean URL instead of `?w_…={"folder":null}`
83
+ * — the hook resolves an absent key to the widget's declared initial value, so
84
+ * absence and default are the same state by construction.
85
+ *
86
+ * @returns {string} the encoded bag, or "" when there is nothing to store (the
87
+ * caller removes the key) or the bag exceeds WIDGET_ROUTE_MAX_CHARS.
88
+ */
89
+ export function encodeWidgetRoute(bag) {
90
+ if (!bag || typeof bag !== "object" || Array.isArray(bag)) return "";
91
+ const storable = {};
92
+ let count = 0;
93
+ for (const key of Object.keys(bag).sort()) {
94
+ const value = bag[key];
95
+ if (value === undefined || value === null) continue;
96
+ if (!isStorableValue(value)) continue;
97
+ storable[key] = value;
98
+ count += 1;
99
+ }
100
+ if (count === 0) return "";
101
+ // Keys are sorted above so an unchanged bag always encodes to the same string
102
+ // — the host compares encodings to decide whether the URL needs rewriting,
103
+ // and key order must not make an identical state look like a change.
104
+ const encoded = JSON.stringify(storable);
105
+ return encoded.length > WIDGET_ROUTE_MAX_CHARS ? "" : encoded;
106
+ }
107
+
108
+ /**
109
+ * Decode what the URL carried back into a bag.
110
+ *
111
+ * Deliberately total: the input is whatever a visitor left in the address bar,
112
+ * so every malformed shape — bad JSON, an array, a nested object, a NaN —
113
+ * resolves to "no stored state" and the widget renders its initial view. It
114
+ * never throws, and it never yields a value a widget could not have written.
115
+ */
116
+ export function decodeWidgetRoute(raw) {
117
+ if (typeof raw !== "string" || raw === "") return {};
118
+ if (raw.length > WIDGET_ROUTE_MAX_CHARS) return {};
119
+ let parsed;
120
+ try {
121
+ parsed = JSON.parse(raw);
122
+ } catch {
123
+ return {};
124
+ }
125
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
126
+ const out = {};
127
+ for (const key of Object.keys(parsed)) {
128
+ const value = parsed[key];
129
+ if (!isStorableValue(value)) continue;
130
+ // A copy, so a widget mutating the array it read cannot reach back into
131
+ // whatever the host is holding.
132
+ out[key] = Array.isArray(value) ? value.slice() : value;
133
+ }
134
+ return out;
135
+ }
136
+
137
+ /**
138
+ * Collect every widget's stored bag out of a raw query-string map, keyed by
139
+ * instance id. The host calls this once per navigation and hands each widget
140
+ * only its own slice.
141
+ */
142
+ export function collectWidgetRoutes(rawParams) {
143
+ const out = {};
144
+ if (!rawParams || typeof rawParams !== "object") return out;
145
+ for (const key of Object.keys(rawParams)) {
146
+ if (!isWidgetRouteKey(key)) continue;
147
+ const instanceId = key.slice(WIDGET_ROUTE_PREFIX.length);
148
+ if (instanceId) out[instanceId] = decodeWidgetRoute(rawParams[key]);
149
+ }
150
+ return out;
151
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.101.0",
3
+ "version": "0.102.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -48,7 +48,7 @@
48
48
  ],
49
49
  "scripts": {
50
50
  "build": "node scripts/build.js",
51
- "test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-invites.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-payments.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-section-empty.test.js src/__tests__/hooks-widget-event.test.js src/__tests__/hooks-widget-input.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-page-url.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-measured-padding.test.js src/__tests__/linter-payment-error.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/theme-components-parity.test.js src/__tests__/navigation-parity.test.js src/__tests__/theme-depth-tokens.test.js src/__tests__/toast-host.test.js src/__tests__/hooks-domain-error-mapping.test.js src/__tests__/linter-datastore-error.test.js src/__tests__/linter-write-gating.test.js src/__tests__/hooks-speech-to-text.test.js src/__tests__/hooks-bound-columns.test.js src/__tests__/hooks-stable-query.test.js src/__tests__/hooks-can-write.test.js"
51
+ "test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-invites.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-payments.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-section-empty.test.js src/__tests__/hooks-widget-event.test.js src/__tests__/hooks-widget-input.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-page-url.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-measured-padding.test.js src/__tests__/linter-payment-error.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/theme-components-parity.test.js src/__tests__/navigation-parity.test.js src/__tests__/theme-depth-tokens.test.js src/__tests__/toast-host.test.js src/__tests__/hooks-domain-error-mapping.test.js src/__tests__/linter-datastore-error.test.js src/__tests__/linter-write-gating.test.js src/__tests__/hooks-speech-to-text.test.js src/__tests__/hooks-bound-columns.test.js src/__tests__/hooks-stable-query.test.js src/__tests__/hooks-can-write.test.js src/__tests__/widget-route.test.js"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=18"