@mosetta/react 0.0.0-stage → 0.0.9
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/CHANGELOG.md +112 -0
- package/LICENSE +21 -0
- package/README.md +46 -2
- package/package.json +30 -3
- package/src/components/BlueprintNodeRenderer.tsx +147 -0
- package/src/components/ComponentErrorHandler.tsx +73 -0
- package/src/components/DefaultRootComponent.tsx +43 -0
- package/src/components/Link.tsx +54 -0
- package/src/hooks/NodeContext.tsx +174 -0
- package/src/hooks/clientState.ts +31 -0
- package/src/hooks/di.tsx +65 -0
- package/src/hooks/useAddToHead.ts +27 -0
- package/src/hooks/useEditorContracts.ts +217 -0
- package/src/hooks/useForceRerender.ts +29 -0
- package/src/hooks/useHref.ts +67 -0
- package/src/hooks/useLocalStorageKey.ts +57 -0
- package/src/hooks/useMediaUpload.ts +20 -0
- package/src/hooks/usePage.ts +34 -0
- package/src/hooks/usePreviewType.ts +31 -0
- package/src/hooks/useResolvedSettings.ts +104 -0
- package/src/hooks/useRoute.ts +19 -0
- package/src/hooks/useRouter.ts +38 -0
- package/src/hooks/useSettingsDraft.ts +110 -0
- package/src/hooks/useShippedSettings.ts +27 -0
- package/src/hooks/useStyler.ts +34 -0
- package/src/hooks/useTheme.ts +189 -0
- package/src/hooks/useTile.tsx +34 -0
- package/src/react.ts +34 -0
- package/src/serverAction.ts +163 -0
- package/src/ssr.tsx +124 -0
- package/src/start.ts +79 -0
- package/src/utils/ProvidedContext.tsx +54 -0
- package/src/utils/definers.ts +190 -0
- package/src/utils/previewSlots.tsx +117 -0
- package/src/utils/tileReference.ts +59 -0
- package/tsconfig.json +3 -0
package/src/react.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// @mosetta/react — the whole react binding in ONE entry. The former /context, /hooks, /tree
|
|
2
|
+
// and /di subpaths were one organism pretending to be four: everything depends on the same two
|
|
3
|
+
// context instances, a version mismatch kills all of them at once, and every application uses
|
|
4
|
+
// them together. One entry → ONE dependency artifact → the singleton discipline holds by
|
|
5
|
+
// construction. The subpaths that remain (`/start`, `/ssr`, `/server-action`) stay out for
|
|
6
|
+
// closure reasons — react-dom and the action client must not ride into every block — and they
|
|
7
|
+
// link THIS artifact externally.
|
|
8
|
+
export * from './utils/definers';
|
|
9
|
+
export * from './hooks/di';
|
|
10
|
+
export * from './utils/ProvidedContext';
|
|
11
|
+
export * from './hooks/NodeContext';
|
|
12
|
+
export * from './utils/tileReference';
|
|
13
|
+
export * from './utils/previewSlots';
|
|
14
|
+
export * from './hooks/clientState';
|
|
15
|
+
export * from './hooks/usePage';
|
|
16
|
+
export * from './hooks/useRoute';
|
|
17
|
+
export * from './hooks/usePreviewType';
|
|
18
|
+
export * from './hooks/useSettingsDraft';
|
|
19
|
+
export * from './hooks/useEditorContracts';
|
|
20
|
+
export * from './hooks/useMediaUpload';
|
|
21
|
+
export * from './hooks/useTile';
|
|
22
|
+
export * from './hooks/useAddToHead';
|
|
23
|
+
export * from './hooks/useShippedSettings';
|
|
24
|
+
export * from './hooks/useStyler';
|
|
25
|
+
export * from './hooks/useTheme';
|
|
26
|
+
export * from './hooks/useHref';
|
|
27
|
+
export * from './hooks/useRouter';
|
|
28
|
+
export * from './hooks/useResolvedSettings';
|
|
29
|
+
export * from './hooks/useLocalStorageKey';
|
|
30
|
+
export { BlueprintRoot, BlueprintNodeRenderer, NODE_ID_ATTRIBUTE } from './components/BlueprintNodeRenderer';
|
|
31
|
+
export { DefaultRootComponent } from './components/DefaultRootComponent';
|
|
32
|
+
export { Link } from './components/Link';
|
|
33
|
+
export type { ILinkProps } from './components/Link';
|
|
34
|
+
export { ComponentErrorHandler } from './components/ComponentErrorHandler';
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// useServerAction v2. ONE hook, the `start` axis:
|
|
2
|
+
// START_FROM_CLIENT (default) — query: auto-starts, cached by <full-id>/<action>#<stable-input>
|
|
3
|
+
// START_MANUAL — mutation: no cache, input arrives at data.start(input)
|
|
4
|
+
// START_FROM_SERVER — the SSR axis; in the browser behaves as a query (a hydration
|
|
5
|
+
// seed from the head cache script covers the precompute)
|
|
6
|
+
//
|
|
7
|
+
// SERVER EXECUTION: when the render carries the ssr channel (→ SsrActionRecorder
|
|
8
|
+
// in DI), START_FROM_SERVER queries run for real through the shared manager — the hook throws
|
|
9
|
+
// the waiter UNCONDITIONALLY (the server render must wait; Suspense-throw is the legacy protocol
|
|
10
|
+
// both runtimes support) and drives the head channel's counter by its rules: begin on the render
|
|
11
|
+
// that ensured the query, complete on the RETRY render that consumed the settled data.
|
|
12
|
+
// The start axis decides WHO pays for the data: START_FROM_CLIENT ships a Loading stub from the
|
|
13
|
+
// server (the client mirrors it and starts the fetch after hydration — parity by stub), manual
|
|
14
|
+
// ships Idle. Channel-less server renders keep the v1 per-hook stubs for everything.
|
|
15
|
+
//
|
|
16
|
+
// DUAL-RUNTIME CONTRACT: this file must run under React (browser) AND preact/compat (the future SSR
|
|
17
|
+
// renderer in the worker — the default React server renderer would tank the isolate's RPS). Only
|
|
18
|
+
// cross-safe primitives: useSyncExternalStore + basic hooks. NO `use()` (absent in preact), no
|
|
19
|
+
// conditional hooks (branches are value-level), no module-level shared state. Opt-in suspense throws
|
|
20
|
+
// the waiter promise — the legacy protocol both runtimes (and preact's renderToStringAsync) support.
|
|
21
|
+
import { useCallback, useState, useSyncExternalStore } from 'react';
|
|
22
|
+
import { useComponent } from '@mosetta/react';
|
|
23
|
+
import { useDi } from '@mosetta/react';
|
|
24
|
+
import {
|
|
25
|
+
EServerActionStart,
|
|
26
|
+
EXECUTION_ENV_TOKEN,
|
|
27
|
+
fullServerTileId,
|
|
28
|
+
ServerActionToken,
|
|
29
|
+
} from '@mosetta/server-action/core';
|
|
30
|
+
import { DI_CONTAINER_TOKEN } from '@mosetta/di';
|
|
31
|
+
import {
|
|
32
|
+
ensureServerActionClient,
|
|
33
|
+
IdleServerData,
|
|
34
|
+
IServerDataHandle,
|
|
35
|
+
LoadingServerData,
|
|
36
|
+
SERVER_DATA_MANAGER,
|
|
37
|
+
ServerData,
|
|
38
|
+
SSR_ACTION_RECORDER_TOKEN,
|
|
39
|
+
stableStringify,
|
|
40
|
+
} from '@mosetta/server-action/client';
|
|
41
|
+
|
|
42
|
+
export interface IUseServerActionOptions<TIn extends {}, TOut> {
|
|
43
|
+
readonly token: ServerActionToken<TIn, TOut>;
|
|
44
|
+
/** query input; MANUAL mode ignores it (the input arrives at data.start(input)) */
|
|
45
|
+
readonly input?: TIn;
|
|
46
|
+
/** fixed at mount (changing it later is a programming error, like changing a hook's order) */
|
|
47
|
+
readonly start?: EServerActionStart;
|
|
48
|
+
/** opt-in suspense: throw the waiter while Loading (browser only; ignored on the server) */
|
|
49
|
+
readonly suspense?: boolean;
|
|
50
|
+
/** merged with token.tags / token.invalidates */
|
|
51
|
+
readonly tags?: readonly string[];
|
|
52
|
+
readonly invalidates?: readonly string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const RESOLVED = Promise.resolve();
|
|
56
|
+
|
|
57
|
+
function mergeTags(a?: readonly string[], b?: readonly string[]): readonly string[] {
|
|
58
|
+
if (!a?.length) return b ?? [];
|
|
59
|
+
if (!b?.length) return a;
|
|
60
|
+
return [...new Set([...a, ...b])];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// v1 server behavior (SSR precompute deferred): fresh PER-HOOK stubs — never module-level singletons
|
|
64
|
+
// (a shared subscriber set across concurrent isolate renders was a legacy leak).
|
|
65
|
+
function createServerHandle<TIn, TOut>(isManual: boolean): IServerDataHandle<TOut, TIn> {
|
|
66
|
+
const snapshot: ServerData<TOut, TIn> = isManual
|
|
67
|
+
? new IdleServerData<TOut, TIn>(() => {
|
|
68
|
+
throw new Error('cannot start a manual server action during server render');
|
|
69
|
+
})
|
|
70
|
+
: new LoadingServerData<TOut, TIn>(RESOLVED);
|
|
71
|
+
return { subscribe: () => () => {}, snapshot: () => snapshot };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function useServerAction<TIn extends {}, TOut>(
|
|
75
|
+
options: IUseServerActionOptions<TIn, TOut>,
|
|
76
|
+
): ServerData<TOut, TIn> {
|
|
77
|
+
const { token } = options;
|
|
78
|
+
// "carry your own" wiring: the hook lazily wires its own client slice into the top
|
|
79
|
+
// scope of the inherited per-request container — no root registration, no wrapper component;
|
|
80
|
+
// the slice code enters a page's closure ONLY through modules that actually call this hook.
|
|
81
|
+
const di = useDi(DI_CONTAINER_TOKEN);
|
|
82
|
+
ensureServerActionClient(di);
|
|
83
|
+
const manager = useDi(SERVER_DATA_MANAGER);
|
|
84
|
+
const { reference } = useComponent();
|
|
85
|
+
|
|
86
|
+
const env = useDi(EXECUTION_ENV_TOKEN);
|
|
87
|
+
const recorder = useDi(SSR_ACTION_RECORDER_TOKEN);
|
|
88
|
+
|
|
89
|
+
// start + env are captured once — the mode is fixed for the hook's lifetime
|
|
90
|
+
const [mode] = useState(() => {
|
|
91
|
+
const start = options.start ?? EServerActionStart.START_FROM_CLIENT;
|
|
92
|
+
const isManual = start === EServerActionStart.START_MANUAL;
|
|
93
|
+
const isServer = env === 'server';
|
|
94
|
+
// real server execution: ONLY the START_FROM_SERVER axis, and it needs the render
|
|
95
|
+
// channel's recorder; everything else on the server renders its v1 stub
|
|
96
|
+
return {
|
|
97
|
+
isManual,
|
|
98
|
+
isServer,
|
|
99
|
+
serverExecutes: isServer && start === EServerActionStart.START_FROM_SERVER && recorder !== null,
|
|
100
|
+
};
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const fullId = fullServerTileId({
|
|
104
|
+
namespace: reference.namespace,
|
|
105
|
+
version: reference.version,
|
|
106
|
+
name: token.serverModuleName ?? reference.name,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// per-hook handle for manual/stub-server modes; queries go through the shared manager cache
|
|
110
|
+
// (on an executing server render too — dedup and the recorder's per-key idempotence line up)
|
|
111
|
+
const [localHandle] = useState<IServerDataHandle<TOut, TIn> | null>(() => {
|
|
112
|
+
if (mode.isServer && !mode.serverExecutes) return createServerHandle<TIn, TOut>(mode.isManual);
|
|
113
|
+
if (mode.isManual) {
|
|
114
|
+
return manager.createManual<TIn, TOut>({
|
|
115
|
+
fullId,
|
|
116
|
+
actionName: token.actionName,
|
|
117
|
+
invalidates: mergeTags(token.invalidates, options.invalidates),
|
|
118
|
+
customSerializer: token.customSerializer,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const key = localHandle ? null : `${fullId}/${token.actionName}#${stableStringify(options.input)}`;
|
|
125
|
+
if (key !== null) {
|
|
126
|
+
// render-phase, but idempotent by key (start-on-first-read); a new input → a new key → a new query
|
|
127
|
+
manager.ensureQuery({
|
|
128
|
+
key,
|
|
129
|
+
tags: mergeTags(token.tags, options.tags),
|
|
130
|
+
request: { fullId, actionName: token.actionName, input: options.input },
|
|
131
|
+
customSerializer: token.customSerializer,
|
|
132
|
+
});
|
|
133
|
+
// the head channel's counter waits for this query from the SAME render that started it
|
|
134
|
+
if (mode.serverExecutes) recorder!.onQueryStart(key);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const subscribe = useCallback(
|
|
138
|
+
(listener: () => void) => (localHandle ? localHandle.subscribe(listener) : manager.subscribe(key!, listener)),
|
|
139
|
+
[manager, localHandle, key],
|
|
140
|
+
);
|
|
141
|
+
const getSnapshot = useCallback(
|
|
142
|
+
() => (localHandle ? localHandle.snapshot() : manager.snapshot<TOut>(key!) as ServerData<TOut, TIn>),
|
|
143
|
+
[manager, localHandle, key],
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const data = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
147
|
+
|
|
148
|
+
if (mode.serverExecutes && key !== null) {
|
|
149
|
+
// UNCONDITIONAL suspense on an executing server render — the page waits for its data;
|
|
150
|
+
// the waiter never rejects (failures fold into an Error snapshot)
|
|
151
|
+
// eslint-disable-next-line @typescript-eslint/only-throw-error -- Suspense protocol
|
|
152
|
+
if (data.isLoading()) throw data.waiter;
|
|
153
|
+
// the retry render consumed the settled data → complete the counter + record for the cache
|
|
154
|
+
recorder!.onQuerySettled(key, data);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// opt-in suspense; never on a stub server render (its snapshot is a resolved-waiter Loading
|
|
158
|
+
// stub — throwing it would loop an async server renderer forever)
|
|
159
|
+
// eslint-disable-next-line @typescript-eslint/only-throw-error -- Suspense protocol (opt-in)
|
|
160
|
+
if (options.suspense && !mode.isServer && data.isLoading()) throw data.waiter;
|
|
161
|
+
|
|
162
|
+
return data;
|
|
163
|
+
}
|
package/src/ssr.tsx
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// @mosetta/react/ssr — the REACT half of a server render: `export default defineSsrSide()`.
|
|
2
|
+
//
|
|
3
|
+
// Everything dialect-specific lives here, and nothing else does. The engine (@mosetta/server/ssr)
|
|
4
|
+
// knows which artifacts enter the isolate and how they link, how the head channel counts, what a
|
|
5
|
+
// preview token means and what a shell must carry; it hands all of that over as DATA. This file
|
|
6
|
+
// knows how to make an element, how to hold the head open until the body settles, and how to turn
|
|
7
|
+
// the result into a stream — which is react's business and no one else's.
|
|
8
|
+
//
|
|
9
|
+
// A page on another binding writes the same two members over the same engine. Nothing here is ever
|
|
10
|
+
// imported by browser code: an ssr facet is built into the isolate grain, which is the only reason
|
|
11
|
+
// react-dom/server may be named at all.
|
|
12
|
+
//
|
|
13
|
+
// NO JSX on purpose: the shell is built with createElement so this file reads the same way the
|
|
14
|
+
// engine's contract does — a tree of data, not markup.
|
|
15
|
+
import { createElement, Suspense, type FC } from 'react';
|
|
16
|
+
import * as react from 'react';
|
|
17
|
+
import * as jsxRuntime from 'react/jsx-runtime';
|
|
18
|
+
import * as reactDom from 'react-dom/server.edge';
|
|
19
|
+
import { renderToReadableStream } from 'react-dom/server.edge';
|
|
20
|
+
import { createRootRenderer } from '@mosetta/server/ssr';
|
|
21
|
+
import { FULL_APP_PREVIEW } from '@mosetta/runtime/client';
|
|
22
|
+
import type { IShellRequest, IRootRenderer } from '@mosetta/server/ssr';
|
|
23
|
+
import { isRightToLeft } from '@mosetta/core';
|
|
24
|
+
|
|
25
|
+
const raw = (html: string) => ({ dangerouslySetInnerHTML: { __html: html } });
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The tags the head channel collected, held behind a boundary until the body settles: an action
|
|
29
|
+
* started deep in the tree may still add a title. Suspense-throw is the protocol both react and
|
|
30
|
+
* preact/compat understand — which is why the wait is expressed this way and not with an await.
|
|
31
|
+
*/
|
|
32
|
+
function headTagsElement(request: IShellRequest) {
|
|
33
|
+
let settled = false;
|
|
34
|
+
const waiting = request.waitHead().then(() => {
|
|
35
|
+
settled = true;
|
|
36
|
+
});
|
|
37
|
+
const HeadTags = () => {
|
|
38
|
+
// eslint-disable-next-line @typescript-eslint/only-throw-error -- Suspense protocol
|
|
39
|
+
if (!settled) throw waiting;
|
|
40
|
+
// the key attribute lets the browser twin (DomHeadWriter) UPSERT over these tags
|
|
41
|
+
return request.headTags().map(([key, entry]) => createElement(entry.tag, {
|
|
42
|
+
key,
|
|
43
|
+
'data-mosetta-head-key': key,
|
|
44
|
+
...entry.attributes,
|
|
45
|
+
...(entry.html !== undefined ? raw(entry.html) : {}),
|
|
46
|
+
}));
|
|
47
|
+
};
|
|
48
|
+
return createElement(Suspense, { fallback: null }, createElement(HeadTags, null));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** the `<html>` shell: boot data in, an element tree out */
|
|
52
|
+
function shellElement(request: IShellRequest) {
|
|
53
|
+
const { envelope, boot, preview } = request;
|
|
54
|
+
const base = envelope.hostBase;
|
|
55
|
+
// a link of another code source is absolute already (ADR-0070); the host's are under its base
|
|
56
|
+
const bootUrlOf = (at: string, path: string): string => (/^[a-z][a-z0-9+.-]*:/i.test(path) ? path : `${at}${path}`);
|
|
57
|
+
// the page's own words in the head (react escapes them): the title a tab and a search result
|
|
58
|
+
// show, the description a result shows under it; the rest of the head is the SEO feature's
|
|
59
|
+
const meta = (envelope as { meta?: { title?: unknown; description?: unknown } }).meta;
|
|
60
|
+
const title = typeof meta?.title === 'string' && meta.title ? meta.title : 'mosetta';
|
|
61
|
+
const description = typeof meta?.description === 'string' && meta.description ? meta.description : null;
|
|
62
|
+
// #pageContent and the boot scripts are NOT here: the engine appends them at the end of <body>
|
|
63
|
+
// once the render finished, so the envelope carries what every node declared it ships
|
|
64
|
+
// (useShippedSettings) — see the engine's withBootTail
|
|
65
|
+
// the root gets the envelope as its tree and the channel in its props — head writes and action
|
|
66
|
+
// counting from anywhere below reach the engine's per-request channel that way
|
|
67
|
+
// a PREVIEW is a preview on the server too: the realm mark below reaches only the browser's
|
|
68
|
+
// container, so the root's prop says it here — blocks that branch on previewness (a hint for
|
|
69
|
+
// an empty list, a placeholder for an analytics script) render the same html the client
|
|
70
|
+
// hydrates against
|
|
71
|
+
const Root = request.app?.Component as FC<{ moduleTreeNode: unknown; ssrChannel: unknown; preview?: { type: string } }> | undefined;
|
|
72
|
+
const appElement = Root
|
|
73
|
+
? createElement(Root, { moduleTreeNode: envelope, ssrChannel: request.channel, ...(preview ? { preview: { type: FULL_APP_PREVIEW } } : {}) })
|
|
74
|
+
: null;
|
|
75
|
+
const lang = (envelope as { route?: { lang?: unknown } }).route?.lang;
|
|
76
|
+
// a right-to-left language lays the whole page out mirrored
|
|
77
|
+
return createElement(
|
|
78
|
+
'html',
|
|
79
|
+
typeof lang === 'string' && lang ? { lang, ...(isRightToLeft(lang) ? { dir: 'rtl' } : {}) } : null,
|
|
80
|
+
createElement(
|
|
81
|
+
'head',
|
|
82
|
+
null,
|
|
83
|
+
createElement('meta', { charSet: 'utf-8' }),
|
|
84
|
+
createElement('meta', { name: 'viewport', content: 'width=device-width, initial-scale=1' }),
|
|
85
|
+
createElement('title', null, title),
|
|
86
|
+
...(description ? [createElement('meta', { name: 'description', content: description })] : []),
|
|
87
|
+
createElement('style', raw('html,body,#root{margin:0;padding:0}')),
|
|
88
|
+
// the declared browser closure: warm the whole artifact wave during parsing (systemjs loads
|
|
89
|
+
// via script injection → classic-script destination, `as="script"`)…
|
|
90
|
+
// SRI: cross-origin integrity requires CORS → crossorigin=anonymous
|
|
91
|
+
...boot.preloadPaths.map((l) => createElement('link', { rel: 'preload', as: 'script', href: bootUrlOf(base, l.path), ...(l.integrity ? { integrity: l.integrity, crossOrigin: 'anonymous' } : {}) })),
|
|
92
|
+
// …and the css of EVERY styled non-lazy tree module — client-rendered parts included; fetched
|
|
93
|
+
// with CORS, hash or not, so the copy the browser keeps carries the CDN's CORS headers and a
|
|
94
|
+
// later CORS read of the same file is answered from it
|
|
95
|
+
...boot.stylePaths.map((l) => createElement('link', { rel: 'stylesheet', href: bootUrlOf(base, l.path), crossOrigin: 'anonymous', ...(l.integrity ? { integrity: l.integrity } : {}) })),
|
|
96
|
+
headTagsElement(request),
|
|
97
|
+
// the flag must exist BEFORE any boot script runs — BlueprintRoot reads it to wrap the tree
|
|
98
|
+
// in ProvidePreview (the whole iframe realm is a preview)
|
|
99
|
+
preview ? createElement('script', raw('window.__mosettaPreview=true')) : null,
|
|
100
|
+
),
|
|
101
|
+
createElement('body', null, createElement('div', { id: 'root' }, appElement)),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function defineSsrSide(): IRootRenderer {
|
|
106
|
+
return createRootRenderer({
|
|
107
|
+
// what client artifacts link against instead of their own react family: every composed
|
|
108
|
+
// version collapses onto these. react-dom/client is named because client artifacts import
|
|
109
|
+
// hydrateRoot at the top level and only CALL it when mounting — never on the server.
|
|
110
|
+
substitutions: {
|
|
111
|
+
react: react,
|
|
112
|
+
'react/jsx-runtime': jsxRuntime,
|
|
113
|
+
'react-dom': reactDom,
|
|
114
|
+
'react-dom/client': reactDom,
|
|
115
|
+
},
|
|
116
|
+
async renderShell(request: IShellRequest): Promise<ReadableStream> {
|
|
117
|
+
const stream = await renderToReadableStream(shellElement(request), { signal: request.signal });
|
|
118
|
+
// buffered mode: await allReady before a byte is read — a late-resolving head boundary
|
|
119
|
+
// would otherwise ship as a fallback plus a patch script
|
|
120
|
+
await stream.allReady;
|
|
121
|
+
return stream;
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
}
|
package/src/start.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// @mosetta/runtime/react — the react-specific start surface: how a tree gets PUT somewhere.
|
|
2
|
+
//
|
|
3
|
+
// Two callers, one implementation. A page boots through its script facet, which is a one-liner
|
|
4
|
+
// over `startAndMount()`; a HOST (the editor showing a block, a panel showing a form) has an
|
|
5
|
+
// element and a tree and asks the root module to mount it there. Rendering a distribution must
|
|
6
|
+
// not require being the page — see ADR-0034.
|
|
7
|
+
//
|
|
8
|
+
// react lives HERE so the `/bootstrap` core stays package-agnostic; as a script facet dependency
|
|
9
|
+
// these imports are externalized like any module's — the SHARED react instance, never a bundled
|
|
10
|
+
// second copy (the hooks dispatcher of a bundled react-dom would not be the one the tree's
|
|
11
|
+
// modules read).
|
|
12
|
+
import { createElement, type FC } from 'react';
|
|
13
|
+
import { hydrateRoot } from 'react-dom/client';
|
|
14
|
+
import { start } from '@mosetta/runtime/bootstrap';
|
|
15
|
+
import type { IBlueprintNode } from '@mosetta/core';
|
|
16
|
+
import type { IMountHandle, IMountOptions, IRootComponentSide, IRootComponentProps } from '@mosetta/react';
|
|
17
|
+
import { DefaultRootComponent } from '@mosetta/react';
|
|
18
|
+
|
|
19
|
+
const MOUNTED = '__mosettaMounted__';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* HYDRATION, always — including into an element with nothing in it.
|
|
23
|
+
*
|
|
24
|
+
* An empty container is not a special case: the first client render is the MIRROR PASS, where a
|
|
25
|
+
* node the server did not render returns a hole. A tree handed over by a host carries no
|
|
26
|
+
* server-render stamp at all, so every node is a hole, so the first pass produces exactly the
|
|
27
|
+
* nothing that is already there — and the holes fill in after mount. One code path for a page
|
|
28
|
+
* that arrived with server HTML and for a panel that arrived empty.
|
|
29
|
+
*/
|
|
30
|
+
export function reactRoot(Component: FC<IRootComponentProps>): IRootComponentSide {
|
|
31
|
+
return {
|
|
32
|
+
Component,
|
|
33
|
+
mount(element: HTMLElement, options: IMountOptions): IMountHandle {
|
|
34
|
+
const app = (tree: IBlueprintNode) => createElement(Component, { moduleTreeNode: tree, parentDi: options.parentDi, preview: options.preview });
|
|
35
|
+
const root = hydrateRoot(element, app(options.tree));
|
|
36
|
+
return {
|
|
37
|
+
// the new tree travels as a prop; the root passes it to its page manager, which is what
|
|
38
|
+
// everything under it actually reads (ADR-0033 §7)
|
|
39
|
+
render: (tree) => root.render(app(tree)),
|
|
40
|
+
unmount: () => root.unmount(),
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** the root a page gets without writing one: DI birth + children, and the mounting to match */
|
|
47
|
+
export const defaultReactRoot = reactRoot(DefaultRootComponent);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `start()` + mount of the page's own root module. Idempotent: two mounting script facets on one
|
|
51
|
+
* page is an authoring error — the second call warns and skips.
|
|
52
|
+
*
|
|
53
|
+
* The root mounts ITSELF: `defineRootComponentSide(defaultReactRoot)` is what an app's root
|
|
54
|
+
* module says, and the same member serves a host that wants this tree somewhere else. react-dom
|
|
55
|
+
* riding into the root's closure costs the server nothing — the isolate substitutes the react
|
|
56
|
+
* family at link time and no longer composes its artifacts (server closure.ts).
|
|
57
|
+
*/
|
|
58
|
+
export async function startAndMount(): Promise<void> {
|
|
59
|
+
const g = globalThis as Record<string, unknown>;
|
|
60
|
+
if (g[MOUNTED]) {
|
|
61
|
+
console.warn('[mosetta] startAndMount: page already mounted — skipped (one mounting script facet per page)');
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
g[MOUNTED] = true;
|
|
65
|
+
|
|
66
|
+
const boot = await start();
|
|
67
|
+
if (!boot) return; // start() already reported the fatal
|
|
68
|
+
const root = boot.tree.reference ? boot.holder.getModule(boot.tree.reference) : undefined;
|
|
69
|
+
const module = root?.module as IRootComponentSide | undefined;
|
|
70
|
+
if (module?.Component == null) {
|
|
71
|
+
console.error('[mosetta] startAndMount: root module has no Component export');
|
|
72
|
+
boot.rootEl.textContent = '[mosetta] runtime failed: root module has no Component export';
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const mount = module.mount ?? reactRoot(module.Component).mount;
|
|
76
|
+
// the boot envelope is a page tree with extras (catalog, host base, ssr stamp) — a superset of
|
|
77
|
+
// what a mount needs
|
|
78
|
+
mount(boot.rootEl, { tree: boot.tree as unknown as IBlueprintNode });
|
|
79
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// THE context wrapper — the one implementation of the Provide/use pair. The binding keeps
|
|
2
|
+
// exactly TWO contexts (the DI container and the node pointer); anything that ever needs another
|
|
3
|
+
// goes through this class, so every context has a NAME and a hard edge: reading it outside a
|
|
4
|
+
// provider is a named error at the read site, never an `undefined` that crashes three calls
|
|
5
|
+
// later. No more hand-rolled createContext pairs, each fragile in its own way.
|
|
6
|
+
import { createContext, ReactNode, useContext, useMemo } from 'react';
|
|
7
|
+
import type { FC } from 'react';
|
|
8
|
+
|
|
9
|
+
interface IBox<T> {
|
|
10
|
+
readonly value: T;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class ProvidedContext<T> {
|
|
14
|
+
/** the provider component; carries the context's name as its displayName (devtools show it) */
|
|
15
|
+
readonly Provide: FC<{ value: T; children?: ReactNode }>;
|
|
16
|
+
|
|
17
|
+
/** the reader; throws a NAMED error when no provider is above */
|
|
18
|
+
readonly use: () => T;
|
|
19
|
+
|
|
20
|
+
/** the tolerant reader — null when no provider is above, for contexts with a sane fallback */
|
|
21
|
+
readonly useOptional: () => T | null;
|
|
22
|
+
|
|
23
|
+
constructor(name: string) {
|
|
24
|
+
// the BOX keeps `undefined` and `null` legal values — only a missing provider reads as null
|
|
25
|
+
const context = createContext<IBox<T> | null>(null);
|
|
26
|
+
context.displayName = name;
|
|
27
|
+
|
|
28
|
+
function Provide({ value, children }: { value: T; children?: ReactNode }) {
|
|
29
|
+
const box = useMemo<IBox<T>>(() => ({ value }), [value]);
|
|
30
|
+
return <context.Provider value={box}>{children}</context.Provider>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// synthetic on purpose (one Provide per instance), so the name is stated explicitly —
|
|
34
|
+
// the convention for every component a factory builds
|
|
35
|
+
Provide.displayName = `${name}.Context`;
|
|
36
|
+
|
|
37
|
+
function use(): T {
|
|
38
|
+
const box = useContext(context);
|
|
39
|
+
if (box === null) {
|
|
40
|
+
throw new Error(`context "${name}" is not provided here — the tree above is missing its Provide`);
|
|
41
|
+
}
|
|
42
|
+
return box.value;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function useOptional(): T | null {
|
|
46
|
+
const box = useContext(context);
|
|
47
|
+
return box === null ? null : box.value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
this.Provide = Provide;
|
|
51
|
+
this.use = use;
|
|
52
|
+
this.useOptional = useOptional;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type { IBlueprintNode } from '@mosetta/core';
|
|
2
|
+
import type { IMountPreview, IRefProviderDeclaration, ISsrRenderChannel, TBindableType } from '@mosetta/runtime/client';
|
|
3
|
+
import type { Constructable, DiContainer } from '@mosetta/di';
|
|
4
|
+
import type { FC } from 'react';
|
|
5
|
+
|
|
6
|
+
export interface IWithComponent<T = {}> {
|
|
7
|
+
readonly Component: FC<T>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface IComponentSide<TSettings> extends IWithComponent {
|
|
11
|
+
readonly defaultSettings: () => TSettings;
|
|
12
|
+
/**
|
|
13
|
+
* This block HANDS OUT values other blocks' settings may reference (`{ "$ref": "<name>/<key>" }`):
|
|
14
|
+
* a name and a pure adapter over this block's own settings. The page resolves references with
|
|
15
|
+
* it while rendering, the editor reads and writes through it.
|
|
16
|
+
*/
|
|
17
|
+
readonly provides?: IRefProviderDeclaration<TSettings>;
|
|
18
|
+
/**
|
|
19
|
+
* The settings of this block that MAY hold a reference instead of a literal — the visible
|
|
20
|
+
* contract a provider's editor relies on when it binds a page's fields — each with the TYPE of
|
|
21
|
+
* what it holds: `{ text: 'text', href: 'url' }`. A provider of that type is offered for the
|
|
22
|
+
* field and takes it under its wing in bulk or automatically (i18n links the texts, never an
|
|
23
|
+
* address). The list form (`['text']`) still works: settings of no declared type, bindable by
|
|
24
|
+
* hand only.
|
|
25
|
+
*/
|
|
26
|
+
readonly bindable?: readonly (keyof TSettings & string)[] | Readonly<Partial<Record<keyof TSettings & string, TBindableType>>>;
|
|
27
|
+
/**
|
|
28
|
+
* This block's editor facet has a part for the PAGE (`<PageInspector>` — i18n's language, a
|
|
29
|
+
* theme's pick for the canvas): the editor mounts it among the canvas tools, with the node's
|
|
30
|
+
* settings as `subject`. A block that `provides` references has one implied.
|
|
31
|
+
*/
|
|
32
|
+
readonly editorChrome?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* How a block asks to be SHOWN before it is placed (the palette's hover preview) — as DATA, so
|
|
37
|
+
* that showing it never requires being written in the host's framework.
|
|
38
|
+
*
|
|
39
|
+
* - `'self'` — "me, with my own default settings"; the common answer, and one word long.
|
|
40
|
+
* - a tree — anything richer: the block with children, inside a wrapper, or under a root module
|
|
41
|
+
* of its own (`reference`) which is free to register whatever services the specimen needs.
|
|
42
|
+
*
|
|
43
|
+
* No declaration, no preview: a block rendered alone out of any composition is a guess, and a
|
|
44
|
+
* guess shown as "this is what you are about to place" is worse than no answer.
|
|
45
|
+
*/
|
|
46
|
+
export type TPreviewSpec = 'self' | IPreviewTree;
|
|
47
|
+
|
|
48
|
+
export interface IPreviewTree {
|
|
49
|
+
/** root module to mount the specimen with; omitted → the host's own */
|
|
50
|
+
readonly reference?: string;
|
|
51
|
+
readonly children: readonly IBlueprintNode[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* One named state of a block worth seeing — "Primary", "Without a description", "Three columns".
|
|
56
|
+
* The workbench shows every variant of a block side by side, live, on the block's own page, and
|
|
57
|
+
* opens any of them in its playground (the block's real settings form, nothing saved).
|
|
58
|
+
*/
|
|
59
|
+
export interface IBlockVariant<TSettings = Record<string, unknown>> {
|
|
60
|
+
/** what the variant is called, short */
|
|
61
|
+
readonly name: string;
|
|
62
|
+
/** a sentence on what it is for */
|
|
63
|
+
readonly description?: string;
|
|
64
|
+
/** settings over the block's `defaultSettings()` */
|
|
65
|
+
readonly settings?: Partial<TSettings>;
|
|
66
|
+
/**
|
|
67
|
+
* What the block holds — a container's blocks, each in its slot. Left out: what the Default
|
|
68
|
+
* variant holds (the block as `preview` declares it, or nothing).
|
|
69
|
+
*/
|
|
70
|
+
readonly children?: readonly IBlueprintNode[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface IEditorSide<TSettings = Record<string, unknown>> extends IWithComponent {
|
|
74
|
+
readonly preview?: TPreviewSpec;
|
|
75
|
+
/**
|
|
76
|
+
* DI services that arrange the SPECIMEN's environment so the LIVE component can render — a
|
|
77
|
+
* seeded editing session, a canned data source. Registered into the preview application's
|
|
78
|
+
* container before anything renders. A preview is the real component in an arranged world:
|
|
79
|
+
* these mock its surroundings, never its markup.
|
|
80
|
+
*/
|
|
81
|
+
readonly previewServices?: readonly Constructable[];
|
|
82
|
+
/**
|
|
83
|
+
* The block's VARIANTS — its states worth seeing side by side, as data. "Default" (the block as
|
|
84
|
+
* it is placed) needs no entry: it always comes first.
|
|
85
|
+
*/
|
|
86
|
+
readonly variants?: readonly IBlockVariant<TSettings>[];
|
|
87
|
+
/**
|
|
88
|
+
* Settings written for an EARLIER version of the block (`from`, `1.2.0`), as this version reads
|
|
89
|
+
* them — called when an author updates a block on a page. Without it, the settings move as they
|
|
90
|
+
* are, and what this version added comes from its `defaultSettings()`.
|
|
91
|
+
*/
|
|
92
|
+
readonly upgrade?: (settings: Record<string, unknown>, from: string) => Partial<TSettings>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface IFallbackSide<TSettings> extends IComponentSide<TSettings> {
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// NO di prop (SSR epic): the container is born INSIDE the Component —
|
|
99
|
+
// `useState(() => createAppContainer(...))` — so the same code path serves browser and server,
|
|
100
|
+
// and a cached server isolate gets a fresh container per request for free.
|
|
101
|
+
export interface IRootComponentProps {
|
|
102
|
+
moduleTreeNode: IBlueprintNode;
|
|
103
|
+
/** the mounting application's container (see IMountOptions.parentDi); absent for a page's own boot */
|
|
104
|
+
readonly parentDi?: DiContainer;
|
|
105
|
+
/** the host's word that this application is a specimen (see IMountOptions.preview) */
|
|
106
|
+
readonly preview?: IMountPreview;
|
|
107
|
+
// stamped by the ssr module during a server render, absent in the browser;
|
|
108
|
+
// the root passes the WHOLE props object into createAppContainer so the channel rides along
|
|
109
|
+
readonly ssrChannel?: ISsrRenderChannel;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** what a host hands over when it asks for a tree to be put somewhere */
|
|
113
|
+
export interface IMountOptions {
|
|
114
|
+
/** an ordinary page document: the root's chrome plus its children */
|
|
115
|
+
readonly tree: IBlueprintNode;
|
|
116
|
+
/**
|
|
117
|
+
* The mounting application's own container — the Unity-editor door (PARENT_DI_TOKEN in the
|
|
118
|
+
* mounted app). A mount is same-realm by construction (it takes a live element) and tokens
|
|
119
|
+
* are string-keyed, so the door survives foreign package copies and foreign bindings; what a
|
|
120
|
+
* host means to EXPOSE it registers under a named token in this container (the settings
|
|
121
|
+
* draft), and everything else is the parent environment, deliberately open at editor time.
|
|
122
|
+
*/
|
|
123
|
+
readonly parentDi?: DiContainer;
|
|
124
|
+
/**
|
|
125
|
+
* The host's word that this application is a SPECIMEN — addressed to the mounted CONTAINER
|
|
126
|
+
* (the type feeds PREVIEW_TYPE_TOKEN; the services are DI classes arranging the specimen's
|
|
127
|
+
* environment).
|
|
128
|
+
*/
|
|
129
|
+
readonly preview?: IMountPreview;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface IMountHandle {
|
|
133
|
+
/** put a different tree in the same application, without building it again */
|
|
134
|
+
render(tree: IBlueprintNode): void;
|
|
135
|
+
unmount(): void;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// The ROOT component module: an ordinary component facet whose Component is the page
|
|
139
|
+
// tree's root (chrome around the tree; DI is born inside it) — and, optionally, the way to PUT
|
|
140
|
+
// that tree somewhere.
|
|
141
|
+
//
|
|
142
|
+
// `mount` exists because rendering a distribution must not require being the page: a host (the
|
|
143
|
+
// editor previewing a block, a panel showing a settings form) has an element and a tree, and asks
|
|
144
|
+
// the root — the only thing that knows how to render its own tree — to do it. The page's own boot
|
|
145
|
+
// goes through the very same call, from the script facet.
|
|
146
|
+
//
|
|
147
|
+
// It is NOT filled in by the definer: this file is imported by every component facet, and a react
|
|
148
|
+
// implementation would drag react-dom into the closure of every block. The react binding hands out
|
|
149
|
+
// a ready pair instead — `defineRootComponentSide(defaultReactRoot)`, or `reactRoot(MyChrome)`
|
|
150
|
+
// for a root with its own chrome.
|
|
151
|
+
//
|
|
152
|
+
// Required, not optional: a half-contract ("some roots can be hosted") would leave every host
|
|
153
|
+
// guessing. The server render pays nothing for it — the isolate substitutes the react family at
|
|
154
|
+
// link time and no longer composes its artifacts at all (server closure.ts).
|
|
155
|
+
export interface IRootComponentSide {
|
|
156
|
+
Component: FC<IRootComponentProps>;
|
|
157
|
+
mount: (element: HTMLElement, options: IMountOptions) => IMountHandle;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function defineRootComponentSide(module: IRootComponentSide) {
|
|
161
|
+
return module;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function defineComponentSide<TSettings>(module: IComponentSide<TSettings>) {
|
|
165
|
+
return module;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function defineEditorSide<TSettings = Record<string, unknown>>(module: IEditorSide<TSettings>) {
|
|
169
|
+
return module;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function defineFallbackSide<TSettings>(module: IFallbackSide<TSettings>) {
|
|
173
|
+
return module;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Marks a module INTERNAL: a block of the tooling (an editor panel, the workbench shell, a preview
|
|
178
|
+
* agent, the editor's own fallback) that the platform's pages place but no author should be
|
|
179
|
+
* offered — the block picker leaves it out. Wraps any definer's result, so a component and a
|
|
180
|
+
* fallback are marked the same way:
|
|
181
|
+
*
|
|
182
|
+
* export default markInternalTile(defineComponentSide({ ... }));
|
|
183
|
+
*
|
|
184
|
+
* Merely importing the marker is the declaration: the build stamps the module `internal` by the
|
|
185
|
+
* import the facet made, the way it stamps a page root. The module itself is untouched.
|
|
186
|
+
*/
|
|
187
|
+
export function markInternalTile<TModule>(module: TModule): TModule {
|
|
188
|
+
return module;
|
|
189
|
+
}
|
|
190
|
+
|