@saykit/react 0.9.0 → 0.10.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
@@ -4,7 +4,7 @@
4
4
 
5
5
  [![Coverage](https://codecov.io/gh/k0d13/saykit/graph/badge.svg?flag=integration-react)](https://codecov.io/gh/k0d13/saykit?flags%5B0%5D=integration-react)
6
6
 
7
- A `<Say>` component for rendering translated content in server and client components, a `<SayProvider>` and `useSay()` for client trees, and a small server runtime (`setSay`, `getSay`, `unstable_createWithSay`).
7
+ A `<Say>` component for rendering translated content in server and client components, a `<SayProvider>` and `useSay()` for client trees, and a `<SayScope>` and `getSay()` that mirror them on the server.
8
8
 
9
9
  ## Install
10
10
 
@@ -17,11 +17,18 @@ You will also need a SayKit build-tool plugin and a `saykit.config.ts`.
17
17
  ## Usage
18
18
 
19
19
  ```tsx
20
- import { Say, SayProvider } from '@saykit/react/client';
20
+ import { Say } from '@saykit/react';
21
+ import { SayProvider } from '@saykit/react/client';
22
+ import { createCatalogue, createStore } from 'saykit';
23
+
24
+ const en = { greeting: 'Hello, {name}!' };
25
+ const fr = { greeting: 'Bonjour, {name} !' };
26
+
27
+ const store = createStore(createCatalogue({ en, fr }), 'fr');
21
28
 
22
29
  function App() {
23
30
  return (
24
- <SayProvider locale="fr" messages={fr}>
31
+ <SayProvider store={store}>
25
32
  <Say>Hello, {name}!</Say>
26
33
  <Say.Plural _={count} one={<>{count} item</>} other={<>{count} items</>} />
27
34
  </SayProvider>
package/dist/client.d.mts CHANGED
@@ -1,31 +1,58 @@
1
1
  import { PropsWithChildren } from "react";
2
- import { ReadonlySay, Say } from "saykit";
2
+ import { Store, View } from "saykit";
3
3
  //#region src/runtime/client.d.ts
4
- type SayRef = {
5
- current: ReadonlySay | null;
6
- };
7
4
  /**
8
- * Provide a localised {@link runtime.Say} instance to descendant **client** components via context.
9
- * Must wrap any component tree using {@link useSay} or {@link Say}.
5
+ * Where a provider takes its view from.
10
6
  *
11
- * The instance is rebuilt whenever `locale` or `messages` changes, so keep `messages`
12
- * referentially stable (module scope, or memoised) rather than passing a fresh object
13
- * literal on every render.
7
+ * A store is the reactive form: it owns a catalogue, so it can switch locale,
8
+ * and every consumer re-renders when it does. Being a live object, it cannot
9
+ * cross the server/client boundary.
14
10
  *
15
- * @param props.locale The current locale
16
- * @param props.messages The current messages for the locale
11
+ * A locale and its messages are the serialisable form a server can hand across
12
+ * that boundary. Only that one locale comes over, so the provider built from
13
+ * it has nothing to switch to: switching is the server's to do, normally
14
+ * through navigation.
17
15
  */
18
- declare function SayProvider({ locale, messages, children }: PropsWithChildren<{
16
+ type SayProviderProps = {
17
+ store: Store;
18
+ locale?: never;
19
+ messages?: never;
20
+ } | {
21
+ store?: never;
19
22
  locale: string;
20
- messages: Say.Messages;
21
- }>): import("react").FunctionComponentElement<import("react").ProviderProps<SayRef>>;
23
+ messages: View.Messages;
24
+ } | {
25
+ store?: never;
26
+ locale?: never;
27
+ messages?: never;
28
+ };
22
29
  /**
23
- * Get the current {@link Say} **client** instance.
30
+ * Provide a {@link View} to descendant **client** components via context.
31
+ * Must wrap any component tree using {@link useSay} or {@link Say}.
32
+ *
33
+ * @param props.store The store to follow, for an application that switches
34
+ * locale on the client
35
+ * @param props.locale The current locale, for one that was given a single
36
+ * locale by the server
37
+ * @param props.messages The messages for that locale, which should be
38
+ * referentially stable rather than a fresh object literal per render
39
+ */
40
+ declare function SayProvider({ store, locale, messages, children }: PropsWithChildren<SayProviderProps>): import("react").FunctionComponentElement<import("react").ProviderProps<Store<string> | null>>;
41
+ /**
42
+ * Get the current {@link View}, on the client.
24
43
  * Must be called within a {@link SayProvider}.
25
44
  *
26
- * @returns The current {@link Say} instance
45
+ * The component re-renders when the store switches locale, so the view this
46
+ * returns is the current one rather than the one the tree first mounted with.
47
+ *
48
+ * There is no hook for the store behind it: a store is a module-scope value,
49
+ * so a locale picker imports the one it built and calls {@link Store.set} on
50
+ * it. A provider given a locale and its messages has no catalogue to switch
51
+ * through anyway.
52
+ *
53
+ * @returns The current {@link View}
27
54
  * @throws If no provider is in the component tree
28
55
  */
29
- declare function useSay(): any;
56
+ declare function useSay(): View;
30
57
  //#endregion
31
- export { SayProvider, useSay };
58
+ export { SayProvider, SayProviderProps, useSay };
package/dist/client.mjs CHANGED
@@ -1,25 +1,25 @@
1
1
  "use client";
2
- import { createContext, createElement, useContext, useMemo } from "react";
3
- import { Say } from "saykit";
2
+ import { createContext, createElement, useContext, useMemo, useSyncExternalStore } from "react";
3
+ import { createCatalogue, createStore } from "saykit";
4
4
  //#region src/runtime/client.ts
5
- const SayContext = createContext({ current: null });
5
+ const SayContext = createContext(null);
6
6
  SayContext.displayName = "SayContext";
7
- function SayProvider({ locale, messages, children }) {
8
- const ref = useMemo(() => {
9
- const instance = new Say({
10
- locales: [locale],
11
- loader: () => messages
12
- });
13
- instance.load(locale);
14
- instance.activate(locale);
15
- return { current: instance.freeze() };
16
- }, [locale, messages]);
17
- return createElement(SayContext.Provider, { value: ref }, children);
7
+ function SayProvider({ store, locale, messages, children }) {
8
+ const held = useMemo(() => {
9
+ if (store) return store;
10
+ if (locale === void 0 || !messages) throw new Error("'SayProvider' must be given a store, or a locale and its messages");
11
+ return createStore(createCatalogue({ [locale]: messages }), locale);
12
+ }, [
13
+ store,
14
+ locale,
15
+ messages
16
+ ]);
17
+ return createElement(SayContext.Provider, { value: held }, children);
18
18
  }
19
19
  function useSay() {
20
- const ref = useContext(SayContext);
21
- if (!ref.current) throw new Error("'useSay' must be used within a 'SayProvider'");
22
- return ref.current;
20
+ const store = useContext(SayContext);
21
+ if (!store) throw new Error("'useSay' must be used within a 'SayProvider'");
22
+ return useSyncExternalStore(useMemo(() => (listener) => store.subscribe(listener), [store]), () => store.say, () => store.say);
23
23
  }
24
24
  //#endregion
25
25
  export { SayProvider, useSay };
@@ -0,0 +1,18 @@
1
+ import { ReactNode } from "react";
2
+ import "server-only";
3
+ //#region src/runtime/client.server.d.ts
4
+ /**
5
+ * The server build of `@saykit/react/client`.
6
+ *
7
+ * A store is a live object and cannot cross the server/client boundary, and a
8
+ * server component cannot hand its own scope to a client one either. What can
9
+ * cross is the locale and its messages, so this reads them off the view the
10
+ * enclosing {@link import('./server.js').SayScope} established and passes them
11
+ * to the real provider, which is why `<SayProvider>` written on the server
12
+ * takes no props.
13
+ */
14
+ declare function SayProvider({ children }: {
15
+ children?: ReactNode;
16
+ }): import("react").FunctionComponentElement<import("react").PropsWithChildren<import("./client.js").SayProviderProps>>;
17
+ //#endregion
18
+ export { SayProvider };
@@ -0,0 +1,14 @@
1
+ import { createElement } from "react";
2
+ import "server-only";
3
+ import { SayProvider as SayProvider$1 } from "./client.mjs";
4
+ import { getSay } from "./server.mjs";
5
+ //#region src/runtime/client.server.ts
6
+ function SayProvider({ children }) {
7
+ const say = getSay();
8
+ return createElement(SayProvider$1, {
9
+ locale: say.locale,
10
+ messages: say.messages
11
+ }, children);
12
+ }
13
+ //#endregion
14
+ export { SayProvider };
package/dist/index.d.mts CHANGED
@@ -3,7 +3,7 @@ import { DateTimeOptions, Disallow, Named, NumberOptions, NumeralOptions, Select
3
3
  //#region src/types.d.ts
4
4
  /**
5
5
  * What a message is allowed to contain. On top of everything React renders, a
6
- * named placeholder `{{ name: value }}` reaches the type checker as a plain
6
+ * named placeholder, `{{ name: value }}`, reaches the type checker as a plain
7
7
  * object child, so the object form has to be part of the contract even though
8
8
  * nothing ever renders it: the transform reads the name off it and compiles the
9
9
  * child away before React sees the tree.
@@ -48,7 +48,7 @@ declare namespace Say {
48
48
  _: number | Named<number>;
49
49
  } & PropsWithJSXSafeKeys<Disallow<NumeralOptions<ReactNode>, 'id' | 'context'>>): ReactNode;
50
50
  /**
51
- * Define an ordinal message (e.g. "1st", "2nd", "3rd").
51
+ * Define an ordinal message ("1st", "2nd", "3rd").
52
52
  *
53
53
  * @example
54
54
  * ```tsx
@@ -70,7 +70,7 @@ declare namespace Say {
70
70
  _: number | Named<number>;
71
71
  } & PropsWithJSXSafeKeys<Disallow<NumeralOptions<ReactNode>, 'id' | 'context'>>): ReactNode;
72
72
  /**
73
- * Define a select message, useful for handling gender, status, or other categories.
73
+ * Define a select message, for gender, status, or other categories.
74
74
  *
75
75
  * @example
76
76
  * ```tsx
@@ -93,7 +93,7 @@ declare namespace Say {
93
93
  /**
94
94
  * Format a number the way the active locale writes one.
95
95
  *
96
- * Unlike `Say.Plural`, `Say.Ordinal`, and `Say.Select`, this is a fragment
96
+ * Unlike `Say.Plural`, `Say.Ordinal` and `Say.Select`, this is a fragment
97
97
  * rather than a whole message, and is normally written inside one.
98
98
  *
99
99
  * @example
@@ -104,8 +104,8 @@ declare namespace Say {
104
104
  * ```
105
105
  *
106
106
  * @param props._ Number to format
107
- * @param props.style Formatting style: a named style, an ICU skeleton such as
108
- * `::currency/EUR`, or a literal number pattern such as `#,##0.00`
107
+ * @param props.style A named style, an ICU skeleton such as
108
+ * `::currency/EUR`, or a pattern such as `#,##0.00`
109
109
  * @returns The formatted number, as a React node
110
110
  * @remark This is a macro and must be used with the relevant saykit plugin
111
111
  */
@@ -122,8 +122,7 @@ declare namespace Say {
122
122
  * ```
123
123
  *
124
124
  * @param props._ Date to format
125
- * @param props.style Formatting style, either a named style or an ICU
126
- * skeleton such as `::yyyyMMdd`
125
+ * @param props.style A named style or an ICU skeleton such as `::yyyyMMdd`
127
126
  * @returns The formatted date, as a React node
128
127
  * @remark This is a macro and must be used with the relevant saykit plugin
129
128
  */
@@ -140,8 +139,7 @@ declare namespace Say {
140
139
  * ```
141
140
  *
142
141
  * @param props._ Date to format
143
- * @param props.style Formatting style, either a named style or an ICU
144
- * skeleton such as `::Hm`
142
+ * @param props.style A named style or an ICU skeleton such as `::Hm`
145
143
  * @returns The formatted time, as a React node
146
144
  * @remark This is a macro and must be used with the relevant saykit plugin
147
145
  */
package/dist/server.d.mts CHANGED
@@ -1,33 +1,77 @@
1
1
  import { ReactNode } from "react";
2
- import { ReadonlySay, Say } from "saykit";
2
+ import { Catalogue, View } from "saykit";
3
3
  import "server-only";
4
4
  //#region src/runtime/server.d.ts
5
5
  /**
6
- * Set the current {@link Say} **server** instance.
7
- * Must be called before any {@link getSay} calls.
6
+ * Get the current {@link View}, on the server.
7
+ * Must be called below a {@link SayScope}.
8
8
  *
9
- * @param say The current {@link Say} instance
10
- */
11
- declare function setSay(say: Say | ReadonlySay | (() => Say | ReadonlySay)): void;
12
- /**
13
- * Get the current {@link Say} **server** instance.
14
- * Must only be called after any {@link setSay} calls.
9
+ * The server counterpart of `useSay`. Reach for it when you need the locale as
10
+ * *data*, to build an `Intl.NumberFormat` say, rather than as a rendered
11
+ * message, which is what `<Say>` is for.
15
12
  *
16
- * @returns The current {@link Say} instance
17
- * @throws If no {@link Say} instance has been set
13
+ * @example
14
+ * ```tsx
15
+ * const say = getSay();
16
+ * const price = new Intl.NumberFormat(say.locale, { style: 'currency', currency }).format(total);
17
+ * ```
18
+ *
19
+ * @returns The current {@link View}
20
+ * @throws If no {@link SayScope} is above the caller
18
21
  */
19
- declare function getSay(): ReadonlySay;
22
+ declare function getSay(): View;
23
+ declare namespace SayScope {
24
+ /**
25
+ * Which view a scope establishes: a catalogue and a locale to negotiate
26
+ * against it, or a view already resolved.
27
+ */
28
+ type Props<Locale extends string = string> = {
29
+ children?: ReactNode;
30
+ } & ({
31
+ catalogue: Catalogue<Locale>;
32
+ locale: Catalogue.Guess;
33
+ view?: never;
34
+ } | {
35
+ view: View<Locale>;
36
+ catalogue?: never;
37
+ locale?: never;
38
+ });
39
+ }
20
40
  /**
21
- * Create a {@link withSay} higher-order component factory bound to a specific {@link Say} instance.
41
+ * Establish the {@link View} for everything rendered inside it, on the server.
42
+ *
43
+ * Given a catalogue and a locale, the locale is negotiated against the
44
+ * catalogue and its messages are loaded before the children render. Given a
45
+ * view, that view is established as it is. Either way `<Say>` and
46
+ * {@link getSay} resolve at any depth below, and the scope is per request.
47
+ *
48
+ * Per request is also the limit. React renders a server component's children
49
+ * after it returns, so a scope does not end where its children do: a second
50
+ * scope takes over for everything rendered after it, including components
51
+ * outside it and the messages `<SayProvider>` serialises. Development warns
52
+ * when that happens. Render another locale in its own request, or resolve its
53
+ * view yourself and pass it to the components that need it.
54
+ *
55
+ * A `<SayProvider>` written inside one takes no props of its own: the server
56
+ * build of `@saykit/react/client` reads the established view and serialises
57
+ * the locale and its messages across the boundary.
58
+ *
59
+ * @example
60
+ * ```tsx
61
+ * <SayScope catalogue={catalogue} locale={locale}>
62
+ * <SayProvider>{children}</SayProvider>
63
+ * </SayScope>
64
+ * ```
22
65
  *
23
- * @param say The {@link Say} instance to bind into the server context
66
+ * @example
67
+ * ```tsx
68
+ * <SayScope view={await catalogue.load('fr')}>{children}</SayScope>
69
+ * ```
24
70
  *
25
- * @returns A {@link withSay} higher-order component factory
71
+ * @param props.catalogue The catalogue to take the view from
72
+ * @param props.locale The locale to negotiate against it
73
+ * @param props.view A view to establish as it is, instead of both of those
26
74
  */
27
- declare function unstable_createWithSay(say: Say): <P = unknown>(Component: (props: PropsWithSay<P>) => ReactNode, getLocale: (props: P) => string | Promise<string>) => (props: P) => Promise<import("react").FunctionComponentElement<PropsWithSay<P>>>;
28
- type PropsWithSay<P = unknown> = P & {
29
- locale: string;
30
- messages: Say.Messages;
31
- };
75
+ declare function SayScope<Locale extends string>({ catalogue, locale, view, children }: SayScope.Props<Locale>): Promise<ReactNode>;
32
76
  //#endregion
33
- export { PropsWithSay, getSay, setSay, unstable_createWithSay };
77
+ export { SayScope, getSay };
package/dist/server.mjs CHANGED
@@ -1,33 +1,26 @@
1
- import { cache, createElement } from "react";
2
- import { Say } from "saykit";
1
+ import { cache } from "react";
3
2
  import "server-only";
4
3
  //#region src/runtime/server.ts
5
- const serverContext = cache(() => ({ current: null }));
6
- function setSay(say) {
7
- const ref = serverContext();
8
- if (say instanceof Say) ref.current = say.clone().freeze();
9
- else ref.current = say().clone().freeze();
10
- }
4
+ const cell = cache(() => ({
5
+ view: void 0,
6
+ warned: false
7
+ }));
8
+ const NO_VIEW = "'getSay' must be called below a 'SayScope'. Wrap the tree in '<SayScope catalogue={catalogue} locale={locale}>'.";
9
+ const NESTED_SCOPE = (established, next) => `A 'SayScope' established '${next}' while '${established}' was already established for this request. A scope is per request rather than per subtree: React renders a server component's children after it returns, so there is nowhere to put the previous view back, and everything rendered after this point reads '${next}' - including components outside the inner scope, and the messages 'SayProvider' serialises to the client. Render the other locale in its own request, or resolve its view yourself and pass it to the components that need it.`;
11
10
  function getSay() {
12
- const ref = serverContext();
13
- if (!ref.current) throw new Error("Attempt to access the server-only Say instance before initialisation", { cause: /* @__PURE__ */ new Error("'getSay' must be called after 'setSay'") });
14
- return ref.current;
11
+ const view = cell().view;
12
+ if (!view) throw new Error(NO_VIEW);
13
+ return view;
15
14
  }
16
- function unstable_createWithSay(say) {
17
- return function withSay(Component, getLocale) {
18
- return async function WithSay(props) {
19
- const guess = await getLocale(props);
20
- const locale = say.match(guess);
21
- await say.load(locale);
22
- say.activate(locale);
23
- setSay(say);
24
- return createElement(Component, {
25
- ...props,
26
- locale: say.locale,
27
- messages: say.messages
28
- });
29
- };
30
- };
15
+ async function SayScope({ catalogue, locale, view, children }) {
16
+ const resolved = view ?? await catalogue.load(catalogue.match(locale));
17
+ const request = cell();
18
+ if (process.env.NODE_ENV !== "production" && !request.warned && request.view && request.view.locale !== resolved.locale) {
19
+ request.warned = true;
20
+ console.warn(NESTED_SCOPE(request.view.locale, resolved.locale));
21
+ }
22
+ request.view = resolved;
23
+ return children;
31
24
  }
32
25
  //#endregion
33
- export { getSay, setSay, unstable_createWithSay };
26
+ export { SayScope, getSay };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saykit/react",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "React integration for saykit, i18n hooks and components",
5
5
  "keywords": [
6
6
  "i18n",
@@ -31,6 +31,7 @@
31
31
  },
32
32
  "./client": {
33
33
  "types": "./dist/client.d.mts",
34
+ "react-server": "./dist/client.server.mjs",
34
35
  "default": "./dist/client.mjs"
35
36
  },
36
37
  "./server": {
@@ -52,7 +53,7 @@
52
53
  "jsdom": "^29.1.1",
53
54
  "react": "^19.2.8",
54
55
  "react-dom": "^19.2.8",
55
- "saykit": "^0.9.0"
56
+ "saykit": "^0.10.0"
56
57
  },
57
58
  "peerDependencies": {
58
59
  "react": "*",