@astrale-os/shell-react 0.4.0-beta.21 → 0.4.0-beta.22

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.
@@ -4,6 +4,7 @@ import type { ShellProviderProps } from '../session/provider.js';
4
4
  export interface AstraleProps {
5
5
  /** Defaults to the child-side sandbox handshake; pass a Shell for an embedded host or test. */
6
6
  readonly config?: ShellConfig | Shell;
7
+ /** Shared loading UI kept mounted across boot and initial content suspension. */
7
8
  readonly fallback?: ReactNode;
8
9
  readonly errorFallback?: ShellProviderProps['errorFallback'];
9
10
  readonly onError?: (error: unknown) => void;
@@ -1,12 +1,25 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Suspense } from 'react';
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Suspense, useLayoutEffect, useState } from 'react';
3
3
  import { ShellProvider } from '../session/provider.js';
4
4
  import { ViewStage } from '../window/stage.js';
5
5
  import { defaultViewError, defaultViewLoading, ViewErrorBoundary } from './boundaries.js';
6
6
  const SANDBOXED_SHELL = Object.freeze({ mode: 'sandboxed' });
7
7
  /** Complete Astrale runtime root without taking ownership of application routing. */
8
8
  export function Astrale(props) {
9
- const loading = props.fallback ?? defaultViewLoading();
9
+ const [loading, setLoading] = useState(true);
10
+ const loadingFallback = _jsx(LoadingPresence, { onChange: setLoading });
10
11
  const errorFallback = props.errorFallback ?? defaultViewError;
11
- return (_jsxs(ShellProvider, { config: props.config ?? SANDBOXED_SHELL, fallback: loading, errorFallback: errorFallback, onError: ({ error }) => props.onError?.(error), children: [_jsx(ViewErrorBoundary, { fallback: errorFallback, onError: props.onError, children: _jsx(Suspense, { fallback: loading, children: props.children }) }), _jsx(ViewStage, {})] }));
12
+ return (_jsxs(_Fragment, { children: [loading ? (props.fallback ?? defaultViewLoading()) : null, _jsxs(ShellProvider, { config: props.config ?? SANDBOXED_SHELL, fallback: loadingFallback, errorFallback: errorFallback, onError: ({ error }) => props.onError?.(error), children: [_jsx(ViewErrorBoundary, { fallback: errorFallback, onError: props.onError, children: _jsx(Suspense, { fallback: loadingFallback, children: props.children }) }), _jsx(ViewStage, {})] })] }));
13
+ }
14
+ /**
15
+ * Boot and content fallbacks are mutually exclusive. Their layout-effect handoffs are
16
+ * batched before paint, so boot → content suspension keeps the same animated DOM
17
+ * node. Cleanup also releases it on errors, retries, and Suspense reveals.
18
+ */
19
+ function LoadingPresence({ onChange }) {
20
+ useLayoutEffect(() => {
21
+ onChange(true);
22
+ return () => onChange(false);
23
+ }, [onChange]);
24
+ return null;
12
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrale-os/shell-react",
3
- "version": "0.4.0-beta.21",
3
+ "version": "0.4.0-beta.22",
4
4
  "description": "Astrale shell-react — the React face of the shell (provider, hooks, view components)",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -32,7 +32,7 @@
32
32
  "zod": "4.4.3"
33
33
  },
34
34
  "devDependencies": {
35
- "@astrale-os/sdk": "0.5.0-beta.146",
35
+ "@astrale-os/sdk": "0.5.0-beta.147",
36
36
  "@testing-library/dom": "^10.4.0",
37
37
  "@testing-library/react": "^16.3.0",
38
38
  "@types/node": "^26.2.0",
@@ -1,7 +1,7 @@
1
1
  import type { Shell, ShellConfig } from '@astrale-os/shell'
2
2
  import type { ReactElement, ReactNode } from 'react'
3
3
 
4
- import { Suspense } from 'react'
4
+ import { Suspense, useLayoutEffect, useState } from 'react'
5
5
 
6
6
  import type { ShellProviderProps } from '../session/provider.js'
7
7
 
@@ -14,6 +14,7 @@ const SANDBOXED_SHELL = Object.freeze({ mode: 'sandboxed' as const })
14
14
  export interface AstraleProps {
15
15
  /** Defaults to the child-side sandbox handshake; pass a Shell for an embedded host or test. */
16
16
  readonly config?: ShellConfig | Shell
17
+ /** Shared loading UI kept mounted across boot and initial content suspension. */
17
18
  readonly fallback?: ReactNode
18
19
  readonly errorFallback?: ShellProviderProps['errorFallback']
19
20
  readonly onError?: (error: unknown) => void
@@ -22,19 +23,36 @@ export interface AstraleProps {
22
23
 
23
24
  /** Complete Astrale runtime root without taking ownership of application routing. */
24
25
  export function Astrale(props: AstraleProps): ReactElement {
25
- const loading = props.fallback ?? defaultViewLoading()
26
+ const [loading, setLoading] = useState(true)
27
+ const loadingFallback = <LoadingPresence onChange={setLoading} />
26
28
  const errorFallback = props.errorFallback ?? defaultViewError
27
29
  return (
28
- <ShellProvider
29
- config={props.config ?? SANDBOXED_SHELL}
30
- fallback={loading}
31
- errorFallback={errorFallback}
32
- onError={({ error }) => props.onError?.(error)}
33
- >
34
- <ViewErrorBoundary fallback={errorFallback} onError={props.onError}>
35
- <Suspense fallback={loading}>{props.children}</Suspense>
36
- </ViewErrorBoundary>
37
- <ViewStage />
38
- </ShellProvider>
30
+ <>
31
+ {loading ? (props.fallback ?? defaultViewLoading()) : null}
32
+ <ShellProvider
33
+ config={props.config ?? SANDBOXED_SHELL}
34
+ fallback={loadingFallback}
35
+ errorFallback={errorFallback}
36
+ onError={({ error }) => props.onError?.(error)}
37
+ >
38
+ <ViewErrorBoundary fallback={errorFallback} onError={props.onError}>
39
+ <Suspense fallback={loadingFallback}>{props.children}</Suspense>
40
+ </ViewErrorBoundary>
41
+ <ViewStage />
42
+ </ShellProvider>
43
+ </>
39
44
  )
40
45
  }
46
+
47
+ /**
48
+ * Boot and content fallbacks are mutually exclusive. Their layout-effect handoffs are
49
+ * batched before paint, so boot → content suspension keeps the same animated DOM
50
+ * node. Cleanup also releases it on errors, retries, and Suspense reveals.
51
+ */
52
+ function LoadingPresence({ onChange }: { readonly onChange: (loading: boolean) => void }): null {
53
+ useLayoutEffect(() => {
54
+ onChange(true)
55
+ return () => onChange(false)
56
+ }, [onChange])
57
+ return null
58
+ }