@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 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
- # Temporary Holding Version
1
+ # @mosetta/react
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ });