@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/CHANGELOG.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# @mosetta/react
|
|
2
|
+
|
|
3
|
+
## 0.0.9
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies
|
|
8
|
+
- @mosetta/runtime@0.0.9
|
|
9
|
+
- @mosetta/server@0.0.9
|
|
10
|
+
- @mosetta/server-action@0.0.9
|
|
11
|
+
- @mosetta/core@0.0.9
|
|
12
|
+
- @mosetta/di@0.0.9
|
|
13
|
+
|
|
14
|
+
## 0.0.8
|
|
15
|
+
|
|
16
|
+
### Patch Changes
|
|
17
|
+
|
|
18
|
+
- Updated dependencies
|
|
19
|
+
- @mosetta/core@0.0.8
|
|
20
|
+
- @mosetta/server@0.0.8
|
|
21
|
+
- @mosetta/server-action@0.0.8
|
|
22
|
+
- @mosetta/di@0.0.8
|
|
23
|
+
- @mosetta/runtime@0.0.8
|
|
24
|
+
|
|
25
|
+
## 0.0.7
|
|
26
|
+
|
|
27
|
+
### Patch Changes
|
|
28
|
+
|
|
29
|
+
- One vocabulary: code is tiles, content is blueprints, running requests is the factory, and a tile and a
|
|
30
|
+
blueprint made in the factory give a mosaic. A tile is a folder under `tiles/` (was `modules/`) whose
|
|
31
|
+
files are its sides, each defined with `define<Side>Side` — `defineComponentSide`, `defineEditorSide`,
|
|
32
|
+
`defineFallbackSide`, `defineRootComponentSide`, `defineServerSide`, `defineSsrSide` (were
|
|
33
|
+
`define…Module`); `useTile` (was `useModule`), `markInternalTile`, `BlueprintRoot`,
|
|
34
|
+
`BlueprintNodeRenderer`. `mosetta.json` names the tile `brand` (was `namespace`; a config that still
|
|
35
|
+
says `namespace` is refused with the word to use). The store connectors' package is `@mosetta/stores`
|
|
36
|
+
(was `@mosetta/provider`): `IStoreConnector`, `StoreConnectors`, `tileHostsOf`. The server's runner
|
|
37
|
+
is the factory gate (`@mosetta/server/factoryGate`, was `/runner`). Nothing on the wire moves: tile
|
|
38
|
+
ids keep `_ns_`, a build keeps `modules/<id>/`, stored JSON keeps its fields.
|
|
39
|
+
- Updated dependencies
|
|
40
|
+
- @mosetta/server-action@0.0.7
|
|
41
|
+
- @mosetta/runtime@0.0.7
|
|
42
|
+
- @mosetta/core@0.0.7
|
|
43
|
+
- @mosetta/server@0.0.7
|
|
44
|
+
- @mosetta/di@0.0.7
|
|
45
|
+
|
|
46
|
+
## 0.0.6
|
|
47
|
+
|
|
48
|
+
### Patch Changes
|
|
49
|
+
|
|
50
|
+
- A site's code may come from several sources — the host's stores, a project's own distributions, another vendor's. A
|
|
51
|
+
store of the registry names its source (`source`; none — the host's), and the runner reads every source's public and
|
|
52
|
+
private catalogs into one: a namespace belongs to the first source holding it, a package to the first that has it
|
|
53
|
+
(other bytes under its id are said, the first stands). Every entity is read from the source its catalog entry names —
|
|
54
|
+
blocks, packages and singletons alike, by the runner, the site's renderer and the browser: the page envelope carries a
|
|
55
|
+
`providers` table (key → base) and the catalog slice the key of each entity. The page no longer says where its code
|
|
56
|
+
lives: the node's `provider` is gone. A server module runs only when a private catalog of the site names it; a site
|
|
57
|
+
whose catalogs were never read runs none (503). A build into a target leaves what the target already holds: released
|
|
58
|
+
library roots and published packages are not built again.
|
|
59
|
+
- Updated dependencies
|
|
60
|
+
- @mosetta/server@0.0.6
|
|
61
|
+
- @mosetta/runtime@0.0.6
|
|
62
|
+
- @mosetta/server-action@0.0.6
|
|
63
|
+
- @mosetta/core@0.0.6
|
|
64
|
+
- @mosetta/di@0.0.6
|
|
65
|
+
|
|
66
|
+
## 0.0.5
|
|
67
|
+
|
|
68
|
+
### Patch Changes
|
|
69
|
+
|
|
70
|
+
- Updated dependencies [92417dc]
|
|
71
|
+
- @mosetta/server-action@0.0.5
|
|
72
|
+
- @mosetta/server@0.0.5
|
|
73
|
+
- @mosetta/core@0.0.5
|
|
74
|
+
- @mosetta/di@0.0.5
|
|
75
|
+
- @mosetta/runtime@0.0.5
|
|
76
|
+
|
|
77
|
+
## 0.0.4
|
|
78
|
+
|
|
79
|
+
### Patch Changes
|
|
80
|
+
|
|
81
|
+
- Updated dependencies [7577aa3]
|
|
82
|
+
- Updated dependencies [7577aa3]
|
|
83
|
+
- @mosetta/server-action@0.0.4
|
|
84
|
+
- @mosetta/server@0.0.4
|
|
85
|
+
- @mosetta/core@0.0.4
|
|
86
|
+
- @mosetta/di@0.0.4
|
|
87
|
+
- @mosetta/runtime@0.0.4
|
|
88
|
+
|
|
89
|
+
## 0.0.3
|
|
90
|
+
|
|
91
|
+
### Patch Changes
|
|
92
|
+
|
|
93
|
+
- 842344f: A page's stylesheets — and its loader — are fetched with CORS whether they carry a hash or not, so the copy a browser
|
|
94
|
+
keeps has the CDN's CORS headers and a later CORS read of the same file is answered from it.
|
|
95
|
+
- Updated dependencies [1330d5d]
|
|
96
|
+
- Updated dependencies [842344f]
|
|
97
|
+
- @mosetta/server@0.0.3
|
|
98
|
+
- @mosetta/server-action@0.0.3
|
|
99
|
+
- @mosetta/core@0.0.3
|
|
100
|
+
- @mosetta/di@0.0.3
|
|
101
|
+
- @mosetta/runtime@0.0.3
|
|
102
|
+
|
|
103
|
+
## 0.0.2
|
|
104
|
+
|
|
105
|
+
### Patch Changes
|
|
106
|
+
|
|
107
|
+
- Updated dependencies
|
|
108
|
+
- @mosetta/server@0.0.2
|
|
109
|
+
- @mosetta/core@0.0.2
|
|
110
|
+
- @mosetta/di@0.0.2
|
|
111
|
+
- @mosetta/runtime@0.0.2
|
|
112
|
+
- @mosetta/server-action@0.0.2
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mosetta contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,47 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @mosetta/react
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The React binding of the mosetta protocol: what a tile's sides are written with.
|
|
4
|
+
|
|
5
|
+
A tile is a folder under `tiles/` whose files are its sides — `tiles/<name>/<name>.<side>.tsx`. The
|
|
6
|
+
component side is what the visitor sees; it reads the settings a content manager filled in the
|
|
7
|
+
editor, and renders the blocks placed into it:
|
|
8
|
+
|
|
9
|
+
```tsx
|
|
10
|
+
import { defineComponentSide, useChildren, useSettings } from '@mosetta/react';
|
|
11
|
+
|
|
12
|
+
interface IGreetingSettings { text: string }
|
|
13
|
+
|
|
14
|
+
export default defineComponentSide<IGreetingSettings>({
|
|
15
|
+
Component() {
|
|
16
|
+
const { text } = useSettings<IGreetingSettings>();
|
|
17
|
+
return <section><h1>{text}</h1>{useChildren({ max: 'many' })}</section>;
|
|
18
|
+
},
|
|
19
|
+
defaultSettings() {
|
|
20
|
+
return { text: 'Hello' };
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The editor side edits the same settings as a draft:
|
|
26
|
+
|
|
27
|
+
```tsx
|
|
28
|
+
import { defineEditorSide, useSettingsDraft } from '@mosetta/react';
|
|
29
|
+
|
|
30
|
+
export default defineEditorSide({
|
|
31
|
+
Component() {
|
|
32
|
+
const { draft, update } = useSettingsDraft<IGreetingSettings>();
|
|
33
|
+
return <input value={draft.text} onChange={(e) => update({ text: e.target.value })} />;
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Entry points
|
|
39
|
+
|
|
40
|
+
| Entry | What it holds |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `@mosetta/react` | side definers (`defineComponentSide`, `defineEditorSide`, `defineFallbackSide`, `defineRootComponentSide`), hooks (`useSettings`, `useChildren`, `useSettingsDraft`, `useTile`, `useRoute`, `useRouter`, `useHref`, `useStyler`, `useTheme`, `useAddToHead`, `useMediaUpload`, …), `Link`, the blueprint renderer |
|
|
43
|
+
| `@mosetta/react/server-action` | `useServerAction` — a tile's call of its server side, run on the server during server rendering |
|
|
44
|
+
| `@mosetta/react/ssr` | the root renderer a tile palette's server rendering is made with |
|
|
45
|
+
| `@mosetta/react/start` | `startAndMount` — the browser entry of a page's root tile |
|
|
46
|
+
|
|
47
|
+
Sources ship as TypeScript: the mosetta build compiles them with the tiles that use them.
|
package/package.json
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mosetta/react",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.0.9",
|
|
4
|
+
"description": "React binding of the mosetta protocol: side definers, hooks, blueprint renderer, DI adapter, server-action hook, startAndMount. Subpath exports only (no barrel) — each entry ships as its own package tile; ./context is the internal state-singleton entry the others re-export from.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/react.ts",
|
|
9
|
+
"./server-action": "./src/serverAction.ts",
|
|
10
|
+
"./ssr": "./src/ssr.tsx",
|
|
11
|
+
"./start": "./src/start.ts"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"react": "*",
|
|
15
|
+
"react-dom": "*",
|
|
16
|
+
"@mosetta/di": "0.0.9",
|
|
17
|
+
"@mosetta/runtime": "0.0.9",
|
|
18
|
+
"@mosetta/server": "0.0.9",
|
|
19
|
+
"@mosetta/core": "0.0.9",
|
|
20
|
+
"@mosetta/server-action": "0.0.9"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/react": "*",
|
|
24
|
+
"@types/react-dom": "*",
|
|
25
|
+
"typescript": "*"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"typecheck": "tsc --noEmit"
|
|
32
|
+
}
|
|
6
33
|
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { INodePointer, IProvidedChild, nodeStatesOf, ProvideNode } from '../hooks/NodeContext';
|
|
2
|
+
import { useHydrated } from '../hooks/clientState';
|
|
3
|
+
import { usePageOf } from '../hooks/usePage';
|
|
4
|
+
import { IComponentSide } from '../utils/definers';
|
|
5
|
+
import { ComponentErrorHandler } from './ComponentErrorHandler';
|
|
6
|
+
import { memo, Suspense, useEffect, useMemo, useState } from 'react';
|
|
7
|
+
import { IWithBlueprintNode } from '@mosetta/core';
|
|
8
|
+
import { useTile } from '../hooks/useTile';
|
|
9
|
+
import { BlockScope, DI_CONTAINER_TOKEN, IWithDi } from '@mosetta/di';
|
|
10
|
+
import { ProvideDi, ProvideDiScope, useDi, useDiScope } from '../hooks/di';
|
|
11
|
+
import { isNodeServerRendered, PAGE_MANAGER_TOKEN } from '@mosetta/runtime/client';
|
|
12
|
+
import type { ISsrFlags } from '@mosetta/runtime/client';
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The tree node's identity in the DOM, carried by the scope host of every rendered node.
|
|
17
|
+
* The editor's preview agent hit-tests and measures through it (`closest('[data-mosetta-node]')`);
|
|
18
|
+
* integration tests address nodes by it too.
|
|
19
|
+
*
|
|
20
|
+
* Emitted ALWAYS, not only under preview: the preview flag is a browser-side realm mark, so a
|
|
21
|
+
* conditional attribute would make the client's first render disagree with the server's HTML and
|
|
22
|
+
* break hydration. The cost is one short attribute per node.
|
|
23
|
+
*
|
|
24
|
+
* The host is `display: contents`, so it generates no box of its own — a reader wanting geometry
|
|
25
|
+
* measures the CONTENTS (a Range over the element), not the element.
|
|
26
|
+
*/
|
|
27
|
+
export const NODE_ID_ATTRIBUTE = 'data-mosetta-node';
|
|
28
|
+
|
|
29
|
+
// Inner body: resolving the module (useTile) and rendering it both happen HERE, INSIDE the error
|
|
30
|
+
// boundary — so a failed module load (useTile throws its ILoadedTile.error) is isolated to this subtree
|
|
31
|
+
// exactly like a render-time error, and falls back instead of crashing the app.
|
|
32
|
+
function ModuleTreeNodeBody({ moduleTreeNode, ssrRendered }: IWithBlueprintNode & { ssrRendered: boolean }) {
|
|
33
|
+
const module = useTile<IComponentSide<unknown>>(moduleTreeNode.reference);
|
|
34
|
+
const Component = module.Component;
|
|
35
|
+
|
|
36
|
+
if (!Component.displayName) {
|
|
37
|
+
Component.displayName = moduleTreeNode.reference;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// the branch's server-renderedness travels to children AS A PROP, not through the pointer: a
|
|
41
|
+
// boolean prop stays identical across edits, so an untouched sibling's memo holds — reading it
|
|
42
|
+
// from the PARENT's pointer would re-render every child whenever the parent's data changed
|
|
43
|
+
const children = useMemo(() => (moduleTreeNode.children ?? []).map<IProvidedChild>(childComponent => {
|
|
44
|
+
return {
|
|
45
|
+
// keyed by id AND module: across a client-side move (ADR-0051) the same id may name another
|
|
46
|
+
// block — a kept fiber would carry its error state and scope over
|
|
47
|
+
// eslint-disable-next-line no-use-before-define -- recursive tree renderer
|
|
48
|
+
child: <BlueprintNodeRenderer key={`${childComponent.id}@${childComponent.reference}`}
|
|
49
|
+
moduleTreeNode={childComponent}
|
|
50
|
+
inheritedSsr={ssrRendered}/>,
|
|
51
|
+
slot: childComponent.slot ?? 'default',
|
|
52
|
+
};
|
|
53
|
+
}), [moduleTreeNode, ssrRendered]);
|
|
54
|
+
|
|
55
|
+
// The node's DATA goes into the DI-keyed store, written as the renderer walks: children read
|
|
56
|
+
// it during THEIR render, which react guarantees comes after this one. The write is idempotent
|
|
57
|
+
// per pass, so StrictMode's double render and a discarded concurrent pass leave nothing torn.
|
|
58
|
+
// The POINTER carries only the id — identity-stable, so the context never propagates and an
|
|
59
|
+
// untouched sibling's memo bailout survives an ancestor's edit (react does not stop context
|
|
60
|
+
// propagation at a nested provider — a data-carrying pointer would re-render every descendant).
|
|
61
|
+
const di = useDi(DI_CONTAINER_TOKEN);
|
|
62
|
+
nodeStatesOf(di).set(moduleTreeNode.id, { node: moduleTreeNode, ssrRendered, children });
|
|
63
|
+
// a node that left the tree takes its state along (the id may return later — with fresh state)
|
|
64
|
+
useEffect(() => () => {
|
|
65
|
+
nodeStatesOf(di).delete(moduleTreeNode.id);
|
|
66
|
+
}, [di, moduleTreeNode.id]);
|
|
67
|
+
|
|
68
|
+
// every block owns a SCOPE, chained to the parent block's: what an ancestor registers for its
|
|
69
|
+
// subtree (a dock panel's tab handle) is resolved by walking this chain — no wrapped containers.
|
|
70
|
+
// Born DURING the render (like the node state above): the children resolve through it in their
|
|
71
|
+
// own renders, which react guarantees come after this one.
|
|
72
|
+
const parentScope = useDiScope();
|
|
73
|
+
const [scope] = useState(() => {
|
|
74
|
+
const blockScope = new BlockScope(parentScope);
|
|
75
|
+
di.registerScope(blockScope);
|
|
76
|
+
return blockScope;
|
|
77
|
+
});
|
|
78
|
+
useEffect(() => () => di.disposeScope(scope), [di, scope]);
|
|
79
|
+
|
|
80
|
+
const pointer = useMemo<INodePointer>(() => ({ id: moduleTreeNode.id }), [moduleTreeNode.id]);
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<ProvideNode value={pointer}>
|
|
84
|
+
<ProvideDiScope scope={scope}>
|
|
85
|
+
{/* layout-neutral scope host: module CSS is prefixed with `.<reference>` */}
|
|
86
|
+
<div className={moduleTreeNode.reference}
|
|
87
|
+
{...{ [NODE_ID_ATTRIBUTE]: moduleTreeNode.id }}
|
|
88
|
+
style={{ display: 'contents' }}>
|
|
89
|
+
<Component />
|
|
90
|
+
</div>
|
|
91
|
+
</ProvideDiScope>
|
|
92
|
+
</ProvideNode>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* MEMOIZED, and that is the whole reason a live tree update is affordable. A content edit builds
|
|
98
|
+
* new objects only along the path to the node it touches (see the content model's tree ops) — every
|
|
99
|
+
* other subtree keeps its identity, so an unchanged node here gets the very same prop and is
|
|
100
|
+
* skipped entirely, together with everything under it. Without this, re-rendering the root would
|
|
101
|
+
* walk the page: siblings of the edited node, and every top-level branch, would render again for
|
|
102
|
+
* a keystroke that has nothing to do with them.
|
|
103
|
+
*
|
|
104
|
+
* The two things that must pierce the memo do: the node pointer is a context (an update reaches
|
|
105
|
+
* consumers through skipped parents), and the hydration gate is a per-renderer store subscription
|
|
106
|
+
* (react re-checks it after hydration on every subscriber, memo or not).
|
|
107
|
+
*/
|
|
108
|
+
export const BlueprintNodeRenderer = memo(function BlueprintNodeRenderer({ moduleTreeNode, inheritedSsr }: IWithBlueprintNode & { inheritedSsr: boolean }) {
|
|
109
|
+
// effective-ssr gate (SSR epic stage 4): during the hydration MIRROR PASS a node the server
|
|
110
|
+
// did not render is a HOLE (null) — the first client render reproduces the server's HTML, so
|
|
111
|
+
// hydrateRoot is safe unconditionally; after mount holes fill in. The same gate prunes the
|
|
112
|
+
// SERVER render to exactly the page's closure (useHydrated is never true there — effects
|
|
113
|
+
// don't run). Branch state arrives as a prop (see the children construction in the body);
|
|
114
|
+
// checked BEFORE useTile — a hole's module is not even in the server holder.
|
|
115
|
+
const hydrated = useHydrated();
|
|
116
|
+
const rendered = isNodeServerRendered(moduleTreeNode as ISsrFlags, inheritedSsr);
|
|
117
|
+
if (!rendered && !hydrated) return null;
|
|
118
|
+
// the boundary + Suspense are in place BEFORE the module resolves. Error boundary OUTSIDE
|
|
119
|
+
// Suspense: a load/render *error* → fallback; a not-yet-loaded module *suspends* (useTile
|
|
120
|
+
// throws the load promise) → Suspense.
|
|
121
|
+
return (
|
|
122
|
+
<ComponentErrorHandler moduleTreeNode={moduleTreeNode}
|
|
123
|
+
ssrRendered={rendered}>
|
|
124
|
+
<Suspense fallback={<></>}>
|
|
125
|
+
<ModuleTreeNodeBody moduleTreeNode={moduleTreeNode}
|
|
126
|
+
ssrRendered={rendered} />
|
|
127
|
+
</Suspense>
|
|
128
|
+
</ComponentErrorHandler>
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// memoized for the same reason as the node renderer: the root maps the page's children on every
|
|
133
|
+
// live update, and a branch whose node did not change must not be walked again.
|
|
134
|
+
export const BlueprintRoot = memo(function BlueprintRoot({ di, moduleTreeNode }: IWithDi & IWithBlueprintNode) {
|
|
135
|
+
// What the first renderer inherits: the envelope's ssrRendered STAMP (NOT the raw ssr flag: a
|
|
136
|
+
// preview or a degraded page carries ssr:true but arrives with an empty #root). Subscribed to
|
|
137
|
+
// the live page, handed down as a boolean prop — the renderer's memo compares it for free.
|
|
138
|
+
const manager = useMemo(() => di.getByToken(PAGE_MANAGER_TOKEN), [di]);
|
|
139
|
+
const page = usePageOf(manager);
|
|
140
|
+
const inheritedSsr = (page as { ssrRendered?: unknown } | null)?.ssrRendered === true;
|
|
141
|
+
return (
|
|
142
|
+
<ProvideDi di={di}>
|
|
143
|
+
<BlueprintNodeRenderer moduleTreeNode={moduleTreeNode}
|
|
144
|
+
inheritedSsr={inheritedSsr} />
|
|
145
|
+
</ProvideDi>
|
|
146
|
+
);
|
|
147
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Component, ErrorInfo, PropsWithChildren, useMemo } from 'react';
|
|
2
|
+
|
|
3
|
+
import { INodePointer, IWithError, nodeStatesOf, ProvideNode } from '../hooks/NodeContext';
|
|
4
|
+
import { usePage } from '../hooks/usePage';
|
|
5
|
+
import { IFallbackSide } from '../utils/definers';
|
|
6
|
+
import { useDi } from '../hooks/di';
|
|
7
|
+
import { DI_CONTAINER_TOKEN } from '@mosetta/di';
|
|
8
|
+
import { TILES_HOLDER_TOKEN } from '@mosetta/runtime/client';
|
|
9
|
+
import type { IWithBlueprintNode } from '@mosetta/core';
|
|
10
|
+
|
|
11
|
+
type TBoundaryProps = PropsWithChildren & IWithBlueprintNode & { ssrRendered: boolean };
|
|
12
|
+
|
|
13
|
+
function FallbackRenderer({ error, moduleTreeNode, ssrRendered }: IWithError & Omit<TBoundaryProps, 'children'>) {
|
|
14
|
+
const page = usePage();
|
|
15
|
+
const holder = useDi(TILES_HOLDER_TOKEN);
|
|
16
|
+
const di = useDi(DI_CONTAINER_TOKEN);
|
|
17
|
+
|
|
18
|
+
// the fallback stands in for the node, so it owns the node's STATE (the body that would have
|
|
19
|
+
// written it is the thing that just died) and a pointer with the error aboard — useError() is
|
|
20
|
+
// a derivation over the same pair as every other hook
|
|
21
|
+
nodeStatesOf(di).set(moduleTreeNode.id, { node: moduleTreeNode, ssrRendered, children: [] });
|
|
22
|
+
const pointer = useMemo<INodePointer>(() => ({
|
|
23
|
+
id: moduleTreeNode.id,
|
|
24
|
+
error,
|
|
25
|
+
}), [moduleTreeNode.id, error]);
|
|
26
|
+
|
|
27
|
+
// Read the fallback via the holder directly (NOT useTile, which throws) so a fallback that is
|
|
28
|
+
// itself missing/failed cannot crash the app — we degrade to a minimal inline message instead.
|
|
29
|
+
const fallbackRef = page && page.fallback;
|
|
30
|
+
const fb = fallbackRef ? holder.getModule(fallbackRef) : undefined;
|
|
31
|
+
const fallbackModule = fb && !fb.error ? (fb.module as IFallbackSide<unknown> | undefined) : undefined;
|
|
32
|
+
|
|
33
|
+
if (!fallbackModule || !fallbackModule.Component) {
|
|
34
|
+
return <div data-mosetta-error="">{String((error && error.message) || error)}</div>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const Fallback = fallbackModule.Component;
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<ProvideNode value={pointer}>
|
|
41
|
+
<Fallback />
|
|
42
|
+
</ProvideNode>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class ComponentErrorHandler extends Component<TBoundaryProps, IWithError> {
|
|
47
|
+
constructor(props: TBoundaryProps) {
|
|
48
|
+
super(props);
|
|
49
|
+
|
|
50
|
+
this.state = {
|
|
51
|
+
error: undefined!,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
static getDerivedStateFromError(error: Error) {
|
|
56
|
+
return { error };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
60
|
+
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
|
61
|
+
// TODO: log error here
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
render() {
|
|
65
|
+
if (this.state.error) {
|
|
66
|
+
return <FallbackRenderer error={this.state.error}
|
|
67
|
+
moduleTreeNode={this.props.moduleTreeNode}
|
|
68
|
+
ssrRendered={this.props.ssrRendered} />;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return <>{this.props.children}</>;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { createAppContainer, PAGE_MANAGER_TOKEN, ROUTER_TOKEN } from '@mosetta/runtime/client';
|
|
3
|
+
import { usePageOf } from '../hooks/usePage';
|
|
4
|
+
import type { IRootComponentProps } from '../utils/definers';
|
|
5
|
+
import { BlueprintRoot } from './BlueprintNodeRenderer';
|
|
6
|
+
|
|
7
|
+
// The framework-provided page root: DI birth + children render — with
|
|
8
|
+
// the root body fully generic (optional slices self-wire from their hooks), an app's root module
|
|
9
|
+
// shrinks to a one-liner over this component. Custom chrome / extra DI registrations — write your
|
|
10
|
+
// own Component against the same IRootComponentProps contract.
|
|
11
|
+
export function DefaultRootComponent(props: IRootComponentProps) {
|
|
12
|
+
// fresh container per mount — on the server that is per request/render of a cached isolate;
|
|
13
|
+
// the WHOLE props object goes in so the server's ssrChannel rides along
|
|
14
|
+
const [di] = useState(() => createAppContainer(props));
|
|
15
|
+
// the tree comes from the page MANAGER, not from the prop it was born with: the manager is where
|
|
16
|
+
// a live composition tool puts a new tree (see PageManager), and the first snapshot is that very
|
|
17
|
+
// prop — so the mirror pass renders exactly what the server sent
|
|
18
|
+
const manager = useMemo(() => di.getByToken(PAGE_MANAGER_TOKEN), [di]);
|
|
19
|
+
// A HOST that mounted this app hands over new trees through the prop (IMountHandle.render), a
|
|
20
|
+
// live composition tool puts them straight into the manager — and the manager is what everything
|
|
21
|
+
// below reads either way. Keyed on the prop, so a tree that arrived the other way is not undone.
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
manager.setCurrentPage(props.moduleTreeNode as Parameters<typeof manager.setCurrentPage>[0]);
|
|
24
|
+
}, [manager, props.moduleTreeNode]);
|
|
25
|
+
const moduleTreeNode = usePageOf(manager);
|
|
26
|
+
// the client router listens to history for as long as the root lives (inert ones listen to
|
|
27
|
+
// nothing): a move swaps the tree through the very manager read above
|
|
28
|
+
useEffect(() => di.getByToken(ROUTER_TOKEN).attach(), [di]);
|
|
29
|
+
return (
|
|
30
|
+
<>
|
|
31
|
+
{/* a ROOT rendered without a tree renders nothing: it is what the palette does when it
|
|
32
|
+
previews a root module as if it were a block, and a crash there says less than an empty
|
|
33
|
+
box does */}
|
|
34
|
+
{/* keyed by id AND module: a node of the next page under a known id but another module is
|
|
35
|
+
another block — its error state and scope must not carry over */}
|
|
36
|
+
{(moduleTreeNode?.children ?? []).map((child) => (
|
|
37
|
+
<BlueprintRoot key={`${child.id}@${child.reference}`}
|
|
38
|
+
di={di}
|
|
39
|
+
moduleTreeNode={child} />
|
|
40
|
+
))}
|
|
41
|
+
</>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { forwardRef, type AnchorHTMLAttributes, type MouseEvent } from 'react';
|
|
2
|
+
import { useRouter } from '../hooks/useRouter';
|
|
3
|
+
|
|
4
|
+
export interface ILinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
|
|
5
|
+
/** the address, as the anchor would carry it (a block resolves a site path with useHref first) */
|
|
6
|
+
readonly href: string;
|
|
7
|
+
/** replace the current history entry instead of adding one */
|
|
8
|
+
readonly replace?: boolean;
|
|
9
|
+
/** scroll to the top once there (default true) */
|
|
10
|
+
readonly scroll?: boolean;
|
|
11
|
+
/** open in a new tab — a plain link then, the router has no say */
|
|
12
|
+
readonly newTab?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** a click the browser should keep: a modifier, another button, a target, a download */
|
|
16
|
+
function browsersOwn(event: MouseEvent<HTMLAnchorElement>, anchor: HTMLAnchorElement): boolean {
|
|
17
|
+
return event.defaultPrevented
|
|
18
|
+
|| event.button !== 0
|
|
19
|
+
|| event.metaKey || event.ctrlKey || event.shiftKey || event.altKey
|
|
20
|
+
|| (anchor.target !== '' && anchor.target !== '_self')
|
|
21
|
+
|| anchor.hasAttribute('download');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A link that moves IN PLACE (ADR-0051): an ordinary `<a href>` whose plain left click asks the
|
|
26
|
+
* client router to move instead of loading a document. Where the router is inert (the server, a
|
|
27
|
+
* preview, a hosted mount) or does not take the address (another origin), the browser does what
|
|
28
|
+
* it always does. The same markup everywhere, so hydration never disagrees. The address is taken
|
|
29
|
+
* as given: a block resolves a path of the site (language, query) with `useHref` before.
|
|
30
|
+
*/
|
|
31
|
+
export const Link = forwardRef<HTMLAnchorElement, ILinkProps>(function Link(
|
|
32
|
+
{ href, replace, scroll, newTab, onClick, children, ...rest },
|
|
33
|
+
ref,
|
|
34
|
+
) {
|
|
35
|
+
const router = useRouter();
|
|
36
|
+
const handleClick = (event: MouseEvent<HTMLAnchorElement>): void => {
|
|
37
|
+
onClick?.(event);
|
|
38
|
+
if (!router.active || newTab || browsersOwn(event, event.currentTarget)) return;
|
|
39
|
+
const address = event.currentTarget.href;
|
|
40
|
+
event.preventDefault();
|
|
41
|
+
void router.navigate(address, { replace, scroll }).then((taken) => {
|
|
42
|
+
if (!taken && typeof window !== 'undefined') window.location.assign(address);
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
return (
|
|
46
|
+
<a ref={ref}
|
|
47
|
+
href={href}
|
|
48
|
+
onClick={handleClick}
|
|
49
|
+
{...(newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
|
50
|
+
{...rest}>
|
|
51
|
+
{children}
|
|
52
|
+
</a>
|
|
53
|
+
);
|
|
54
|
+
});
|