hono-preact 0.4.0 → 0.5.1
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/dist/iso/define-routes.d.ts +0 -1
- package/dist/iso/define-routes.js +10 -3
- package/dist/iso/form.js +6 -0
- package/dist/iso/index.d.ts +1 -1
- package/dist/iso/index.js +1 -1
- package/dist/iso/internal/history-shim.d.ts +13 -0
- package/dist/iso/internal/history-shim.js +31 -0
- package/dist/iso/internal/page-middleware-host.js +148 -45
- package/dist/iso/internal/route-change.d.ts +17 -0
- package/dist/iso/internal/route-change.js +365 -45
- package/dist/iso/internal/timeout-error.d.ts +5 -0
- package/dist/iso/internal/timeout-error.js +9 -0
- package/dist/iso/internal.d.ts +1 -1
- package/dist/iso/internal.js +1 -1
- package/dist/iso/view-transition-types.d.ts +11 -0
- package/dist/iso/view-transition-types.js +30 -15
- package/dist/vite/client-entry.js +7 -9
- package/package.json +2 -1
|
@@ -51,7 +51,6 @@ export type RoutesManifest = {
|
|
|
51
51
|
export declare function defineRoutes(tree: RouteDef[]): RoutesManifest;
|
|
52
52
|
export type RoutesProps = {
|
|
53
53
|
routes: RoutesManifest;
|
|
54
|
-
onRouteChange?: (url: string) => void;
|
|
55
54
|
};
|
|
56
55
|
export declare const Routes: ComponentType<RoutesProps>;
|
|
57
56
|
export {};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { h } from 'preact';
|
|
2
2
|
import { lazy, Route, Router, useLocation } from 'preact-iso';
|
|
3
3
|
import { RouteLocationsProvider } from './internal/route-locations.js';
|
|
4
|
+
import { __noteLoadEnd, __noteLoadStart } from './internal/route-change.js';
|
|
4
5
|
function wrapWithRouteLocations(serverMod, location, node) {
|
|
5
6
|
const moduleKey = serverMod
|
|
6
7
|
?.__moduleKey;
|
|
@@ -158,7 +159,10 @@ function makeLayoutGroupComponent(layoutImport, server, layoutPathPattern, child
|
|
|
158
159
|
const inner = buildInnerRoutes(children, viewCache);
|
|
159
160
|
const Wrapper = (location) => {
|
|
160
161
|
const layoutLocation = deriveLayoutLocation(location, layoutPathPattern);
|
|
161
|
-
const layoutNode = h(Layout, null, h(Router,
|
|
162
|
+
const layoutNode = h(Layout, null, h(asRouteComponent(Router), {
|
|
163
|
+
onLoadStart: __noteLoadStart,
|
|
164
|
+
onLoadEnd: __noteLoadEnd,
|
|
165
|
+
}, ...inner));
|
|
162
166
|
return wrapWithRouteLocations(serverMod, layoutLocation, layoutNode);
|
|
163
167
|
};
|
|
164
168
|
return { default: Wrapper };
|
|
@@ -276,8 +280,11 @@ export function defineRoutes(tree) {
|
|
|
276
280
|
serverRoutes: collectServerRoutes(tree),
|
|
277
281
|
};
|
|
278
282
|
}
|
|
279
|
-
export const Routes = ({ routes
|
|
280
|
-
return h(asRouteComponent(Router),
|
|
283
|
+
export const Routes = ({ routes }) => {
|
|
284
|
+
return h(asRouteComponent(Router), {
|
|
285
|
+
onLoadStart: __noteLoadStart,
|
|
286
|
+
onLoadEnd: __noteLoadEnd,
|
|
287
|
+
}, ...routes.flat.map((r) => h(Route, {
|
|
281
288
|
key: r.key,
|
|
282
289
|
path: r.path,
|
|
283
290
|
component: asRouteComponent(r.component),
|
package/dist/iso/form.js
CHANGED
|
@@ -34,6 +34,12 @@ export function Form({ action, children, ...rest }) {
|
|
|
34
34
|
? window.location.pathname + window.location.search
|
|
35
35
|
: '/';
|
|
36
36
|
const fd = new FormData(formEl);
|
|
37
|
+
// Source the action identity from props, not the DOM hidden inputs. On an
|
|
38
|
+
// initial SSR page those inputs render empty (server-side defineAction
|
|
39
|
+
// carries no name metadata) and Preact's hydrate() does not patch their
|
|
40
|
+
// values, so reading them back would post __module/__action='' and 404.
|
|
41
|
+
fd.set('__module', moduleKey);
|
|
42
|
+
fd.set('__action', actionName);
|
|
37
43
|
const payload = collectFormData(fd);
|
|
38
44
|
let handle;
|
|
39
45
|
if (optimistic)
|
package/dist/iso/index.d.ts
CHANGED
|
@@ -38,7 +38,7 @@ export type { HeadProps } from './head.js';
|
|
|
38
38
|
export { ClientScript } from './client-script.js';
|
|
39
39
|
export { useViewTransitionLifecycle, type ViewTransitionLifecycle, type ViewTransitionPhaseCallback, } from './view-transition-lifecycle.js';
|
|
40
40
|
export type { ViewTransitionEvent, NavDirection, ViewTransitionReason, } from './internal/view-transition-event.js';
|
|
41
|
-
export { useViewTransitionTypes, type ViewTransitionTypesInput, type ViewTransitionTypesNav, } from './view-transition-types.js';
|
|
41
|
+
export { useViewTransitionTypes, subscribeViewTransitionTypes, type ViewTransitionTypesInput, type ViewTransitionTypesNav, } from './view-transition-types.js';
|
|
42
42
|
export { getNavDirection as getViewTransitionDirection } from './internal/history-shim.js';
|
|
43
43
|
export { useViewTransitionName, useViewTransitionClass, ViewTransitionName, ViewTransitionGroup, type ViewTransitionNameProps, type ViewTransitionGroupProps, } from './view-transition-name.js';
|
|
44
44
|
export { Persist, PersistHost, type PersistProps } from './persist.js';
|
package/dist/iso/index.js
CHANGED
|
@@ -35,7 +35,7 @@ export { ClientScript } from './client-script.js';
|
|
|
35
35
|
// View transition lifecycle hook.
|
|
36
36
|
export { useViewTransitionLifecycle, } from './view-transition-lifecycle.js';
|
|
37
37
|
// View transitions types.
|
|
38
|
-
export { useViewTransitionTypes, } from './view-transition-types.js';
|
|
38
|
+
export { useViewTransitionTypes, subscribeViewTransitionTypes, } from './view-transition-types.js';
|
|
39
39
|
export { getNavDirection as getViewTransitionDirection } from './internal/history-shim.js';
|
|
40
40
|
// View transition name + group hooks and components.
|
|
41
41
|
export { useViewTransitionName, useViewTransitionClass, ViewTransitionName, ViewTransitionGroup, } from './view-transition-name.js';
|
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import type { NavDirection } from './view-transition-event.js';
|
|
2
|
+
export declare function onNavigation(listener: () => void): () => void;
|
|
2
3
|
export declare function installHistoryShim(): void;
|
|
3
4
|
export declare function getNavDirection(): NavDirection;
|
|
5
|
+
/**
|
|
6
|
+
* True once any client-side navigation (push, replace, or popstate) has
|
|
7
|
+
* occurred since the page loaded. Before the first navigation the document is
|
|
8
|
+
* still showing its server-rendered, freshly hydrated route. PageMiddlewareHost
|
|
9
|
+
* uses this to pick its render strategy: on the initial load it renders the
|
|
10
|
+
* server children during hydration and applies the client middleware outcome
|
|
11
|
+
* afterwards (so a Suspense boundary never resolves to non-SSR content
|
|
12
|
+
* mid-hydration, which would orphan the server route DOM); after a navigation
|
|
13
|
+
* it suspends on the chain normally. `lastDirection` only ever moves away from
|
|
14
|
+
* 'initial' (never back), so this is a monotonic "have we navigated yet" flag.
|
|
15
|
+
*/
|
|
16
|
+
export declare function hasClientNavigated(): boolean;
|
|
4
17
|
/** Test-only reset. Do not call from production code. */
|
|
5
18
|
export declare function resetHistoryShimForTesting(): void;
|
|
6
19
|
/** Test-only direction setter. Do not call from production code. */
|
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
let installed = false;
|
|
2
2
|
let counter = 0;
|
|
3
3
|
let lastDirection = 'initial';
|
|
4
|
+
// Fires synchronously whenever a navigation occurs (pushState / replaceState /
|
|
5
|
+
// popstate), BEFORE the resulting re-render. Lets the view-transition scheduler
|
|
6
|
+
// observe a navigation at navigation time rather than only at render time (which
|
|
7
|
+
// Preact's render batching can coalesce away). See route-change.ts.
|
|
8
|
+
const navListeners = new Set();
|
|
9
|
+
export function onNavigation(listener) {
|
|
10
|
+
navListeners.add(listener);
|
|
11
|
+
return () => navListeners.delete(listener);
|
|
12
|
+
}
|
|
13
|
+
function notifyNavigation() {
|
|
14
|
+
for (const listener of navListeners)
|
|
15
|
+
listener();
|
|
16
|
+
}
|
|
4
17
|
let originalPush = null;
|
|
5
18
|
let originalReplace = null;
|
|
6
19
|
let popstateListener = null;
|
|
@@ -28,6 +41,7 @@ export function installHistoryShim() {
|
|
|
28
41
|
};
|
|
29
42
|
originalPush(merged, title, url);
|
|
30
43
|
lastDirection = 'push';
|
|
44
|
+
notifyNavigation();
|
|
31
45
|
};
|
|
32
46
|
history.replaceState = function patchedReplace(state, title, url) {
|
|
33
47
|
const merged = {
|
|
@@ -36,12 +50,14 @@ export function installHistoryShim() {
|
|
|
36
50
|
};
|
|
37
51
|
originalReplace(merged, title, url);
|
|
38
52
|
lastDirection = 'replace';
|
|
53
|
+
notifyNavigation();
|
|
39
54
|
};
|
|
40
55
|
popstateListener = (e) => {
|
|
41
56
|
const incoming = e.state?.__hpVtIdx ?? 0;
|
|
42
57
|
lastDirection =
|
|
43
58
|
incoming < counter ? 'back' : incoming > counter ? 'forward' : 'replace';
|
|
44
59
|
counter = incoming;
|
|
60
|
+
notifyNavigation();
|
|
45
61
|
};
|
|
46
62
|
window.addEventListener('popstate', popstateListener, { capture: true });
|
|
47
63
|
// Stamp the current entry so subsequent diffs are well-defined.
|
|
@@ -52,6 +68,20 @@ export function installHistoryShim() {
|
|
|
52
68
|
export function getNavDirection() {
|
|
53
69
|
return lastDirection;
|
|
54
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* True once any client-side navigation (push, replace, or popstate) has
|
|
73
|
+
* occurred since the page loaded. Before the first navigation the document is
|
|
74
|
+
* still showing its server-rendered, freshly hydrated route. PageMiddlewareHost
|
|
75
|
+
* uses this to pick its render strategy: on the initial load it renders the
|
|
76
|
+
* server children during hydration and applies the client middleware outcome
|
|
77
|
+
* afterwards (so a Suspense boundary never resolves to non-SSR content
|
|
78
|
+
* mid-hydration, which would orphan the server route DOM); after a navigation
|
|
79
|
+
* it suspends on the chain normally. `lastDirection` only ever moves away from
|
|
80
|
+
* 'initial' (never back), so this is a monotonic "have we navigated yet" flag.
|
|
81
|
+
*/
|
|
82
|
+
export function hasClientNavigated() {
|
|
83
|
+
return lastDirection !== 'initial';
|
|
84
|
+
}
|
|
55
85
|
/** Test-only reset. Do not call from production code. */
|
|
56
86
|
export function resetHistoryShimForTesting() {
|
|
57
87
|
if (installed &&
|
|
@@ -72,6 +102,7 @@ export function resetHistoryShimForTesting() {
|
|
|
72
102
|
originalPush = null;
|
|
73
103
|
originalReplace = null;
|
|
74
104
|
popstateListener = null;
|
|
105
|
+
navListeners.clear();
|
|
75
106
|
}
|
|
76
107
|
/** Test-only direction setter. Do not call from production code. */
|
|
77
108
|
export function setNavDirectionForTesting(dir) {
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx } from "preact/jsx-runtime";
|
|
2
2
|
import { useLocation } from 'preact-iso';
|
|
3
3
|
import { Suspense } from 'preact/compat';
|
|
4
|
-
import { useContext, useEffect, useRef } from 'preact/hooks';
|
|
4
|
+
import { useContext, useEffect, useRef, useState } from 'preact/hooks';
|
|
5
5
|
import { isBrowser } from '../is-browser.js';
|
|
6
6
|
import { isRedirect, isRender } from '../outcomes.js';
|
|
7
7
|
import { dispatchServer, dispatchClient } from './middleware-runner.js';
|
|
8
8
|
import { partitionUse } from './use-partitioner.js';
|
|
9
9
|
import wrapPromise from './wrap-promise.js';
|
|
10
|
+
import { hasClientNavigated } from './history-shim.js';
|
|
10
11
|
import { HonoRequestContext } from './contexts.js';
|
|
11
12
|
function startChain(use, location, honoCtx) {
|
|
12
13
|
const { middleware } = partitionUse(use);
|
|
@@ -44,68 +45,147 @@ function startChain(use, location, honoCtx) {
|
|
|
44
45
|
inner: async () => undefined,
|
|
45
46
|
}).then((r) => r.kind === 'outcome' ? { outcome: r.outcome } : { outcome: undefined });
|
|
46
47
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const { route } = useLocation();
|
|
53
|
-
// Client-side redirect: navigate in an effect rather than during render.
|
|
54
|
-
// Render-time side effects are forbidden by Suspense semantics; doing
|
|
55
|
-
// route() in render would also fire on every Preact re-entry during
|
|
56
|
-
// suspension resume. Keyed on the resolved target so a fresh outcome
|
|
57
|
-
// for the same path doesn't refire (the outcome is cached per chain,
|
|
58
|
-
// so this only changes when the path itself changes and a new chain
|
|
59
|
-
// produces a redirect to a different target).
|
|
60
|
-
const redirectTo = isRedirect(outcome) && isBrowser() ? outcome.to : null;
|
|
61
|
-
useEffect(() => {
|
|
62
|
-
if (redirectTo !== null)
|
|
63
|
-
route(redirectTo);
|
|
64
|
-
// `route` is intentionally omitted from deps: it comes from
|
|
65
|
-
// useLocation() which is stable per LocationProvider mount, and
|
|
66
|
-
// referencing it here would re-fire the effect on every render the
|
|
67
|
-
// provider produces.
|
|
68
|
-
}, [redirectTo]);
|
|
48
|
+
/**
|
|
49
|
+
* Applies a settled host outcome to the rendered tree. Shared rendering logic
|
|
50
|
+
* for both host strategies (Suspense and Deferred).
|
|
51
|
+
*/
|
|
52
|
+
function renderOutcome(outcome, children) {
|
|
69
53
|
if (outcome === undefined) {
|
|
70
54
|
return _jsx(_Fragment, { children: children });
|
|
71
55
|
}
|
|
72
56
|
if (isRedirect(outcome)) {
|
|
73
57
|
if (isBrowser()) {
|
|
74
|
-
//
|
|
75
|
-
//
|
|
58
|
+
// The navigation is scheduled in an effect; render nothing meanwhile so
|
|
59
|
+
// the old tree doesn't briefly flash.
|
|
76
60
|
return null;
|
|
77
61
|
}
|
|
78
|
-
// Server: rethrow so renderPage's outer handler can translate to HTTP redirect.
|
|
62
|
+
// Server: rethrow so renderPage's outer handler can translate to an HTTP redirect.
|
|
79
63
|
throw outcome;
|
|
80
64
|
}
|
|
81
65
|
if (isRender(outcome)) {
|
|
82
66
|
const Alt = outcome.Component;
|
|
83
|
-
// Equality-by-reference semantics: each `render(Component)` call
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
// can wrap the returned component or vary props.
|
|
67
|
+
// Equality-by-reference semantics: each `render(Component)` call returns a
|
|
68
|
+
// fresh outcome object, but the wrapped chain caches its result for the
|
|
69
|
+
// lifetime of a path. Within the same path render outcomes are stable.
|
|
70
|
+
// Across paths the chain is re-dispatched, so a fresh chain produces a
|
|
71
|
+
// fresh outcome and Preact remounts naturally when `Alt` differs. If a
|
|
72
|
+
// middleware returns the SAME component reference across paths, Preact
|
|
73
|
+
// treats it as the same element and preserves state. That's the documented
|
|
74
|
+
// semantic; callers needing a forced remount can wrap the component or vary
|
|
75
|
+
// props.
|
|
93
76
|
return _jsx(Alt, {});
|
|
94
77
|
}
|
|
95
78
|
// Deny on the page-render path: rethrow so the outer error boundary or
|
|
96
79
|
// handler can translate to the right response.
|
|
97
80
|
throw outcome;
|
|
98
81
|
}
|
|
99
|
-
|
|
100
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Suspense strategy: suspend on the middleware chain and render its outcome.
|
|
84
|
+
* Used for SSR (prerender awaits the suspension) and for post-navigation client
|
|
85
|
+
* renders (no hydration to mismatch, a fallback is fine while the chain runs).
|
|
86
|
+
*/
|
|
87
|
+
function HostConsumer({ resultRef, children, }) {
|
|
88
|
+
// resultRef.current is populated by the parent before this consumer
|
|
89
|
+
// renders; the null branch is just a type-narrow guard.
|
|
90
|
+
const wrapped = resultRef.current;
|
|
91
|
+
const { outcome } = wrapped ? wrapped.read() : { outcome: undefined };
|
|
92
|
+
const { route } = useLocation();
|
|
93
|
+
// Client-side redirect: navigate in an effect rather than during render
|
|
94
|
+
// (render-time side effects are forbidden by Suspense semantics, and route()
|
|
95
|
+
// in render would also re-fire on every Preact re-entry during suspension
|
|
96
|
+
// resume). Keyed on the resolved target so a fresh outcome for the same path
|
|
97
|
+
// doesn't refire.
|
|
98
|
+
const redirectTo = isRedirect(outcome) && isBrowser() ? outcome.to : null;
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (redirectTo === null)
|
|
101
|
+
return;
|
|
102
|
+
// A plain SPA route() is correct here. This consumer only runs on the
|
|
103
|
+
// server (where the redirect outcome is thrown above, not routed) and for
|
|
104
|
+
// post-navigation client renders; initial-load redirects are handled by
|
|
105
|
+
// DeferredHost, which renders the SSR content during hydration so there is
|
|
106
|
+
// no server-committed tree for the Router to orphan. (The
|
|
107
|
+
// orphan-on-hydration-mismatch is expected Preact behavior, see
|
|
108
|
+
// preactjs/preact#4442.)
|
|
109
|
+
route(redirectTo);
|
|
110
|
+
// `route` is intentionally omitted from deps: it comes from useLocation()
|
|
111
|
+
// which is stable per LocationProvider mount, and referencing it here would
|
|
112
|
+
// re-fire the effect on every render the provider produces.
|
|
113
|
+
}, [redirectTo]);
|
|
114
|
+
return renderOutcome(outcome, children);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Deferred strategy: used on the INITIAL document load (browser, before any
|
|
118
|
+
* client navigation). Renders the server-rendered children during hydration so
|
|
119
|
+
* the hydrated DOM matches SSR, then runs the client chain post-hydration and
|
|
120
|
+
* applies its outcome as a normal update. This avoids a Suspense boundary
|
|
121
|
+
* resolving to non-SSR content mid-hydration, which orphans the server route
|
|
122
|
+
* DOM (expected Preact behavior, preactjs/preact#4442) and stacks the redirect
|
|
123
|
+
* target on top (the "client redirect double-mount").
|
|
124
|
+
*
|
|
125
|
+
* Timing contract: on the initial load the client middleware runs AFTER
|
|
126
|
+
* hydration (in the effect), not before first paint. The server already
|
|
127
|
+
* authorized and rendered this content, so a client guard is advisory here and
|
|
128
|
+
* applies once hydrated; anything it would redirect away from is already on
|
|
129
|
+
* screen from SSR, so deferring exposes nothing new. Post-navigation renders
|
|
130
|
+
* take SuspenseHost, where the chain blocks the render as before.
|
|
131
|
+
*
|
|
132
|
+
* Known limitation: this matches the COMMON case where the server passed and
|
|
133
|
+
* rendered `children`. If a SERVER middleware instead rendered an alternative
|
|
134
|
+
* (so SSR markup != children) and the client produces no matching outcome, the
|
|
135
|
+
* client still renders `children` and the SSR/client mismatch is on the user -
|
|
136
|
+
* the framework does not transmit the server outcome to the client. This is
|
|
137
|
+
* pre-existing (it predates the deferred strategy) and out of scope here.
|
|
138
|
+
*/
|
|
139
|
+
function DeferredHost({ use, location, honoCtx, children, }) {
|
|
140
|
+
const { route } = useLocation();
|
|
141
|
+
// null = chain not yet settled, or settled to a redirect/pass: keep rendering
|
|
142
|
+
// the server children. A settled render()/deny outcome is stored so it can be
|
|
143
|
+
// swapped in via a normal post-hydration update.
|
|
144
|
+
const [applied, setApplied] = useState(null);
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
let cancelled = false;
|
|
147
|
+
// Reset to the server children before re-dispatching, so a stale render()
|
|
148
|
+
// outcome from a previous path cannot linger if this host is ever reused
|
|
149
|
+
// across a path change (in practice it only handles the initial load, but
|
|
150
|
+
// a persisted layout host could in principle see location.path change).
|
|
151
|
+
setApplied(null);
|
|
152
|
+
startChain(use, location, honoCtx).then((result) => {
|
|
153
|
+
if (cancelled)
|
|
154
|
+
return;
|
|
155
|
+
if (isRedirect(result.outcome)) {
|
|
156
|
+
// SPA navigate from the fully hydrated tree. Because the server's
|
|
157
|
+
// content (not null) was rendered during hydration, there is no
|
|
158
|
+
// orphaned route DOM for the Router to stack the target on top of.
|
|
159
|
+
route(result.outcome.to);
|
|
160
|
+
}
|
|
161
|
+
else if (result.outcome !== undefined) {
|
|
162
|
+
// render() / deny: surface after hydration via a normal update.
|
|
163
|
+
setApplied(result);
|
|
164
|
+
}
|
|
165
|
+
// undefined (chain passed): keep the already-rendered children.
|
|
166
|
+
});
|
|
167
|
+
return () => {
|
|
168
|
+
cancelled = true;
|
|
169
|
+
};
|
|
170
|
+
// Re-dispatch if the path changes (mirrors SuspenseHost's per-path dispatch).
|
|
171
|
+
}, [location.path]);
|
|
172
|
+
if (applied === null)
|
|
173
|
+
return _jsx(_Fragment, { children: children });
|
|
174
|
+
return renderOutcome(applied.outcome, children);
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Suspense strategy wrapper. Lazily dispatches the chain once per path (see the
|
|
178
|
+
* lazy-ref note below) and renders the outcome through HostConsumer.
|
|
179
|
+
*/
|
|
180
|
+
function SuspenseHost({ use, location, honoCtx, fallback, children, }) {
|
|
101
181
|
// Lazy ref pattern. `useRef(null)` serves a dual purpose: it's the
|
|
102
|
-
// "not-yet-computed" sentinel AND the persistent slot for the wrapped
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
182
|
+
// "not-yet-computed" sentinel AND the persistent slot for the wrapped chain
|
|
183
|
+
// result. We compute on first render and on subsequent renders ONLY when the
|
|
184
|
+
// path changed. `useRef(wrapPromise(startChain(...)))` would evaluate
|
|
185
|
+
// `startChain` every render before useRef decided whether to keep it, which
|
|
186
|
+
// synchronously fires `dispatchServer`/`dispatchClient` every render. That's
|
|
187
|
+
// O(renders) middleware invocations instead of O(navigations); auth checks,
|
|
188
|
+
// analytics, redirects would all repeat.
|
|
109
189
|
const resultRef = useRef(null);
|
|
110
190
|
const prevPath = useRef(location.path);
|
|
111
191
|
if (resultRef.current === null) {
|
|
@@ -116,4 +196,27 @@ export const PageMiddlewareHost = ({ use = [], location, fallback, children }) =
|
|
|
116
196
|
resultRef.current = wrapPromise(startChain(use, location, honoCtx));
|
|
117
197
|
}
|
|
118
198
|
return (_jsx(Suspense, { fallback: fallback, children: _jsx(HostConsumer, { resultRef: resultRef, children: children }) }));
|
|
199
|
+
}
|
|
200
|
+
export const PageMiddlewareHost = ({ use = [], location, fallback, children }) => {
|
|
201
|
+
const honoCtx = useContext(HonoRequestContext).context;
|
|
202
|
+
// Choose the render strategy ONCE per mount so the hook order stays stable
|
|
203
|
+
// across renders (hasClientNavigated() flips to true after the first
|
|
204
|
+
// navigation, which would otherwise change which child - and its hooks - we
|
|
205
|
+
// render).
|
|
206
|
+
//
|
|
207
|
+
// Initial document load (browser, no navigation yet) -> DeferredHost: a
|
|
208
|
+
// Suspense boundary that suspends during hydration and resolves to non-SSR
|
|
209
|
+
// content (e.g. null for a redirect) orphans the server-rendered route DOM
|
|
210
|
+
// (expected Preact behavior, preactjs/preact#4442), which the Router then
|
|
211
|
+
// stacks the redirect target on top of. Rendering the server children during
|
|
212
|
+
// hydration and applying the client outcome afterwards removes that mismatch.
|
|
213
|
+
//
|
|
214
|
+
// Server render and post-navigation client renders have no hydration to
|
|
215
|
+
// mismatch, so the Suspense path (suspend on the chain, render the outcome)
|
|
216
|
+
// is correct there.
|
|
217
|
+
const deferRef = useRef(isBrowser() && !hasClientNavigated());
|
|
218
|
+
if (deferRef.current) {
|
|
219
|
+
return (_jsx(DeferredHost, { use: use, location: location, honoCtx: honoCtx, children: children }));
|
|
220
|
+
}
|
|
221
|
+
return (_jsx(SuspenseHost, { use: use, location: location, honoCtx: honoCtx, fallback: fallback, children: children }));
|
|
119
222
|
};
|
|
@@ -4,6 +4,23 @@ type PhaseSub = (event: ViewTransitionEvent) => void | Promise<void>;
|
|
|
4
4
|
type LegacySub = (to: string, from: string | undefined) => void;
|
|
5
5
|
export declare function __subscribePhase(phase: PhaseName, sub: PhaseSub): () => void;
|
|
6
6
|
export declare function __subscribeRouteChange(sub: LegacySub): () => void;
|
|
7
|
+
export declare function __noteLoadStart(): void;
|
|
8
|
+
export declare function __noteLoadEnd(): void;
|
|
9
|
+
/** @internal Test-only reset for coordinator state. */
|
|
10
|
+
export declare function __resetTransitionStateForTesting(): void;
|
|
11
|
+
/**
|
|
12
|
+
* @internal Install the view-transition render scheduler (client only).
|
|
13
|
+
*
|
|
14
|
+
* Takes ownership of `options.debounceRendering`: it captures the previous value
|
|
15
|
+
* as `prevDebounce` (delegated to for every non-navigation flush) and installs
|
|
16
|
+
* `scheduleRender` in its place. This assumes nothing else permanently overrides
|
|
17
|
+
* `options.debounceRendering` afterward. `preact/compat`'s `flushSync` swaps it
|
|
18
|
+
* temporarily and restores it, so that composes fine; a second permanent
|
|
19
|
+
* override would shadow this scheduler (the install is idempotent, so calling it
|
|
20
|
+
* twice is a no-op, not a double-install). Reversed by
|
|
21
|
+
* `__resetTransitionStateForTesting`.
|
|
22
|
+
*/
|
|
23
|
+
export declare function installNavTransitionScheduler(): void;
|
|
7
24
|
export declare function __dispatchRouteChange(to: string, from: string | undefined): void;
|
|
8
25
|
/** @internal Test-only reset for default-types installer. */
|
|
9
26
|
export declare function resetDefaultTypesForTesting(): void;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { options } from 'preact';
|
|
2
2
|
import { ViewTransitionEvent, } from './view-transition-event.js';
|
|
3
|
-
import { getNavDirection } from './history-shim.js';
|
|
3
|
+
import { getNavDirection, onNavigation } from './history-shim.js';
|
|
4
4
|
const phaseSubs = {
|
|
5
5
|
beforeTransition: new Set(),
|
|
6
6
|
beforeSwap: new Set(),
|
|
@@ -30,71 +30,391 @@ function getStartViewTransition() {
|
|
|
30
30
|
const fn = document.startViewTransition;
|
|
31
31
|
return typeof fn === 'function' ? fn.bind(document) : undefined;
|
|
32
32
|
}
|
|
33
|
-
|
|
33
|
+
function currentPath() {
|
|
34
|
+
return typeof location !== 'undefined'
|
|
35
|
+
? location.pathname + location.search
|
|
36
|
+
: '';
|
|
37
|
+
}
|
|
38
|
+
// `loadingDepth`: how many Routers are mid-suspense (via onLoadStart/onLoadEnd).
|
|
39
|
+
// Read right after a navigation commits to tell whether the route suspended (a
|
|
40
|
+
// cold navigation), so the transition can wait for the suspended content.
|
|
41
|
+
let loadingDepth = 0;
|
|
42
|
+
export function __noteLoadStart() {
|
|
43
|
+
loadingDepth++;
|
|
44
|
+
}
|
|
45
|
+
export function __noteLoadEnd() {
|
|
46
|
+
loadingDepth = Math.max(0, loadingDepth - 1);
|
|
47
|
+
}
|
|
48
|
+
// The path the app is currently on (the previous navigation's `to`); seeds
|
|
49
|
+
// `from` for the first navigation. A server render leaves it undefined.
|
|
50
|
+
let lastPath = typeof location !== 'undefined'
|
|
51
|
+
? location.pathname + location.search
|
|
52
|
+
: undefined;
|
|
53
|
+
// Cold-navigation state: a navigation's transition holds until the suspending
|
|
54
|
+
// route's content flushes have all run (see the scheduler below).
|
|
55
|
+
let coldTimeout = null;
|
|
56
|
+
// Bumped per navigation so a superseded transition's (async) callback bows out.
|
|
57
|
+
let navGen = 0;
|
|
58
|
+
// Cap on how long a navigation holds the old snapshot waiting for a suspending
|
|
59
|
+
// route's content. Past it the navigation completes without finishing the
|
|
60
|
+
// transition rather than freezing the page on a slow/stalled load.
|
|
61
|
+
const COLD_COMMIT_TIMEOUT_MS = 500;
|
|
62
|
+
// Extra grace, after the shell is ready, to let a morph partner that loads with
|
|
63
|
+
// the route's DATA (behind inner Suspense, which doesn't move loadingDepth)
|
|
64
|
+
// appear in the new snapshot before the transition captures it.
|
|
65
|
+
const MORPH_PARTNER_GRACE_MS = 150;
|
|
66
|
+
/** @internal Test-only reset for coordinator state. */
|
|
67
|
+
export function __resetTransitionStateForTesting() {
|
|
68
|
+
loadingDepth = 0;
|
|
69
|
+
navGen = 0;
|
|
70
|
+
lastPath =
|
|
71
|
+
typeof location !== 'undefined'
|
|
72
|
+
? location.pathname + location.search
|
|
73
|
+
: undefined;
|
|
74
|
+
coldRouteSignal = null;
|
|
75
|
+
if (coldTimeout !== null) {
|
|
76
|
+
clearTimeout(coldTimeout);
|
|
77
|
+
coldTimeout = null;
|
|
78
|
+
}
|
|
79
|
+
transitionActive = false;
|
|
80
|
+
// Uninstall the render scheduler (restoring Preact's debounceRendering) so
|
|
81
|
+
// each test can install it fresh.
|
|
82
|
+
if (schedulerInstalled) {
|
|
83
|
+
options.debounceRendering = prevDebounce;
|
|
84
|
+
schedulerInstalled = false;
|
|
85
|
+
prevDebounce = undefined;
|
|
86
|
+
if (unsubscribeNav) {
|
|
87
|
+
unsubscribeNav();
|
|
88
|
+
unsubscribeNav = null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function fireAfterSwap(event) {
|
|
93
|
+
for (const sub of phaseSubs.afterSwap)
|
|
94
|
+
sub(event);
|
|
95
|
+
// Legacy subscribers fire at the afterSwap slot: after the DOM swap, before
|
|
96
|
+
// the browser begins animating the new frame.
|
|
97
|
+
fireLegacy(event.to, event.from);
|
|
98
|
+
}
|
|
99
|
+
function fireAfterTransition(event, reason) {
|
|
100
|
+
if (reason !== undefined)
|
|
101
|
+
event.reason = reason;
|
|
102
|
+
for (const sub of phaseSubs.afterTransition)
|
|
103
|
+
sub(event);
|
|
104
|
+
}
|
|
105
|
+
function applyTypes(transition, types) {
|
|
106
|
+
const vtTypes = transition.types;
|
|
107
|
+
if (vtTypes && typeof vtTypes.add === 'function') {
|
|
108
|
+
for (const t of types)
|
|
109
|
+
vtTypes.add(t);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function skipTransition(transition) {
|
|
113
|
+
const t = transition;
|
|
114
|
+
if (typeof t.skipTransition === 'function')
|
|
115
|
+
t.skipTransition();
|
|
116
|
+
}
|
|
117
|
+
// Build the event for a navigation that has just committed: `to`/`direction`
|
|
118
|
+
// are only correct after the commit (pushState updates the history shim), so
|
|
119
|
+
// this is always called post-commit. Advances `lastPath`.
|
|
120
|
+
function buildEvent(from) {
|
|
34
121
|
ensureDefaultTypes();
|
|
122
|
+
const to = currentPath();
|
|
123
|
+
lastPath = to;
|
|
35
124
|
const direction = getNavDirection();
|
|
36
125
|
const event = new ViewTransitionEvent({ to, from, direction });
|
|
37
126
|
for (const sub of phaseSubs.beforeTransition)
|
|
38
127
|
sub(event);
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
128
|
+
return event;
|
|
129
|
+
}
|
|
130
|
+
let schedulerInstalled = false;
|
|
131
|
+
let lastHref = '';
|
|
132
|
+
let prevDebounce;
|
|
133
|
+
let unsubscribeNav = null;
|
|
134
|
+
// True while a navigation's transition is in flight (its callback is pending or
|
|
135
|
+
// awaiting cold content). Lets the navigation observer abandon it.
|
|
136
|
+
let transitionActive = false;
|
|
137
|
+
// Set while a cold navigation's transition awaits a content flush; the next
|
|
138
|
+
// same-URL flush hands its `process` here (or `null` on supersede/timeout) so
|
|
139
|
+
// the transition can run it inside itself.
|
|
140
|
+
let coldRouteSignal = null;
|
|
141
|
+
function defaultSchedule(process) {
|
|
142
|
+
if (prevDebounce)
|
|
143
|
+
prevDebounce(process);
|
|
144
|
+
else
|
|
145
|
+
Promise.resolve().then(process);
|
|
146
|
+
}
|
|
147
|
+
// Fired (via the history shim) at navigation time, before the re-render. If a
|
|
148
|
+
// transition from a previous navigation is still in flight, abandon it here:
|
|
149
|
+
// Preact may coalesce the new navigation's render into the in-flight one, so
|
|
150
|
+
// scheduleRender never sees it and its own supersede branch can't fire.
|
|
151
|
+
function onNavObserved() {
|
|
152
|
+
if (!transitionActive && !coldRouteSignal)
|
|
56
153
|
return;
|
|
154
|
+
navGen++; // the in-flight callback bows out at its next navGen check
|
|
155
|
+
transitionActive = false;
|
|
156
|
+
if (coldRouteSignal) {
|
|
157
|
+
const resolve = coldRouteSignal;
|
|
158
|
+
coldRouteSignal = null;
|
|
159
|
+
if (coldTimeout !== null) {
|
|
160
|
+
clearTimeout(coldTimeout);
|
|
161
|
+
coldTimeout = null;
|
|
162
|
+
}
|
|
163
|
+
resolve(null);
|
|
57
164
|
}
|
|
58
|
-
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* @internal Install the view-transition render scheduler (client only).
|
|
168
|
+
*
|
|
169
|
+
* Takes ownership of `options.debounceRendering`: it captures the previous value
|
|
170
|
+
* as `prevDebounce` (delegated to for every non-navigation flush) and installs
|
|
171
|
+
* `scheduleRender` in its place. This assumes nothing else permanently overrides
|
|
172
|
+
* `options.debounceRendering` afterward. `preact/compat`'s `flushSync` swaps it
|
|
173
|
+
* temporarily and restores it, so that composes fine; a second permanent
|
|
174
|
+
* override would shadow this scheduler (the install is idempotent, so calling it
|
|
175
|
+
* twice is a no-op, not a double-install). Reversed by
|
|
176
|
+
* `__resetTransitionStateForTesting`.
|
|
177
|
+
*/
|
|
178
|
+
export function installNavTransitionScheduler() {
|
|
179
|
+
if (schedulerInstalled)
|
|
180
|
+
return;
|
|
181
|
+
if (typeof document === 'undefined' || typeof location === 'undefined')
|
|
182
|
+
return;
|
|
183
|
+
schedulerInstalled = true;
|
|
184
|
+
lastHref = location.href;
|
|
185
|
+
prevDebounce = options.debounceRendering;
|
|
186
|
+
options.debounceRendering = scheduleRender;
|
|
187
|
+
unsubscribeNav = onNavigation(onNavObserved);
|
|
188
|
+
}
|
|
189
|
+
function scheduleRender(process) {
|
|
190
|
+
const href = location.href;
|
|
191
|
+
const navigated = href !== lastHref;
|
|
192
|
+
// The content flush for an in-flight cold navigation (same URL): hand it back
|
|
193
|
+
// to that transition so it lands in the new snapshot.
|
|
194
|
+
if (coldRouteSignal && !navigated) {
|
|
195
|
+
const resolve = coldRouteSignal;
|
|
196
|
+
coldRouteSignal = null;
|
|
197
|
+
if (coldTimeout !== null) {
|
|
198
|
+
clearTimeout(coldTimeout);
|
|
199
|
+
coldTimeout = null;
|
|
200
|
+
}
|
|
201
|
+
resolve(process); // the transition's callback runs `process()` itself
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
// A new navigation arrived while a cold one was still loading: abandon it.
|
|
205
|
+
if (coldRouteSignal && navigated) {
|
|
206
|
+
navGen++;
|
|
207
|
+
const resolve = coldRouteSignal;
|
|
208
|
+
coldRouteSignal = null;
|
|
209
|
+
if (coldTimeout !== null) {
|
|
210
|
+
clearTimeout(coldTimeout);
|
|
211
|
+
coldTimeout = null;
|
|
212
|
+
}
|
|
213
|
+
resolve(null);
|
|
214
|
+
}
|
|
215
|
+
if (navigated) {
|
|
216
|
+
// Reset the load counter at the start of a navigation. A previous route's
|
|
217
|
+
// loads are abandoned by a new navigation, and preact-iso fires onLoadStart
|
|
218
|
+
// without a matching onLoadEnd when a still-suspended Router unmounts (it
|
|
219
|
+
// emits onLoadEnd only on a committed render, not on unmount). Left alone,
|
|
220
|
+
// that leaked depth would make this nav (and later ones) look perpetually
|
|
221
|
+
// cold and burn the cold-load timeout. This nav re-increments it as its own
|
|
222
|
+
// route suspends.
|
|
223
|
+
loadingDepth = 0;
|
|
224
|
+
}
|
|
225
|
+
lastHref = href;
|
|
226
|
+
const start = navigated ? getStartViewTransition() : undefined;
|
|
59
227
|
if (!start) {
|
|
60
|
-
|
|
61
|
-
fireAfterSwap();
|
|
62
|
-
fireAfterTransition('unsupported');
|
|
228
|
+
defaultSchedule(process);
|
|
63
229
|
return;
|
|
64
230
|
}
|
|
65
|
-
|
|
66
|
-
|
|
231
|
+
runNavTransition(process, start);
|
|
232
|
+
}
|
|
233
|
+
// Wait for the next content flush of an in-flight cold navigation (routed here
|
|
234
|
+
// by scheduleRender), or null on timeout/supersede.
|
|
235
|
+
function waitForColdFlush(myGen, timeoutMs) {
|
|
236
|
+
return new Promise((resolve) => {
|
|
237
|
+
coldRouteSignal = resolve;
|
|
238
|
+
coldTimeout = setTimeout(() => {
|
|
239
|
+
if (navGen === myGen && coldRouteSignal) {
|
|
240
|
+
coldRouteSignal = null;
|
|
241
|
+
coldTimeout = null;
|
|
242
|
+
resolve(null);
|
|
243
|
+
}
|
|
244
|
+
}, timeoutMs);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
// Elements carrying an inline `view-transition-name`. The attribute selector
|
|
248
|
+
// lets the browser filter natively instead of walking every node in JS — this
|
|
249
|
+
// runs on the frozen hot path (inside the transition callback, possibly once per
|
|
250
|
+
// grace tick), so the candidate set should be as small as possible. The selector
|
|
251
|
+
// is a substring match on the serialized `style` attribute, so we still confirm
|
|
252
|
+
// each match by reading the resolved property below.
|
|
253
|
+
function queryVtNamedElements() {
|
|
254
|
+
if (typeof document === 'undefined' || !document.querySelectorAll)
|
|
255
|
+
return [];
|
|
256
|
+
return Array.from(document.querySelectorAll('[style*="view-transition-name"]'));
|
|
257
|
+
}
|
|
258
|
+
// The view-transition-names currently applied in the document, mapped to the
|
|
259
|
+
// element carrying each (first wins on the off-chance a name is duplicated).
|
|
260
|
+
// Element identity is what lets the grace tell a persistent name (still on its
|
|
261
|
+
// original node) from a freshly-materialised morph endpoint (see below).
|
|
262
|
+
function collectVtNameElements() {
|
|
263
|
+
const map = new Map();
|
|
264
|
+
for (const el of queryVtNamedElements()) {
|
|
265
|
+
const n = el.style?.getPropertyValue?.('view-transition-name');
|
|
266
|
+
if (n && !map.has(n))
|
|
267
|
+
map.set(n, el);
|
|
268
|
+
}
|
|
269
|
+
return map;
|
|
270
|
+
}
|
|
271
|
+
// Whether a name from the outgoing route now appears on a DIFFERENT (or new)
|
|
272
|
+
// element than it did before the swap — i.e. a genuine destination morph
|
|
273
|
+
// endpoint has materialised. A name still carried by its ORIGINAL element is
|
|
274
|
+
// persistent chrome (e.g. a parent layout's title that doesn't unmount across
|
|
275
|
+
// the nav). Such a name pairs trivially on its own and must NOT satisfy the
|
|
276
|
+
// grace: if it did, the grace would be skipped while the real data-loaded
|
|
277
|
+
// partner (which loads behind inner Suspense, so it doesn't move loadingDepth)
|
|
278
|
+
// is still pending, and the new snapshot would be captured without it.
|
|
279
|
+
function hasFreshMorphPartner(oldNamed) {
|
|
280
|
+
if (oldNamed.size === 0)
|
|
281
|
+
return false;
|
|
282
|
+
for (const el of queryVtNamedElements()) {
|
|
283
|
+
const n = el.style?.getPropertyValue?.('view-transition-name');
|
|
284
|
+
if (n && oldNamed.has(n) && oldNamed.get(n) !== el)
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
function runNavTransition(process, start) {
|
|
290
|
+
const from = lastPath;
|
|
291
|
+
// The named elements present in the outgoing route, keyed by name. Used to
|
|
292
|
+
// know when a morph partner has freshly appeared in the new route (see the
|
|
293
|
+
// grace wait below) — element identity distinguishes a persistent name (same
|
|
294
|
+
// node) from a destination endpoint that re-claims the name on a new node.
|
|
295
|
+
const oldNamed = collectVtNameElements();
|
|
296
|
+
const myGen = ++navGen;
|
|
297
|
+
transitionActive = true;
|
|
298
|
+
let transition;
|
|
299
|
+
let event;
|
|
300
|
+
try {
|
|
301
|
+
transition = start(async () => {
|
|
302
|
+
// The old snapshot has been captured. Flush the navigation render.
|
|
303
|
+
process();
|
|
304
|
+
if (navGen !== myGen)
|
|
305
|
+
return;
|
|
306
|
+
event = buildEvent(from);
|
|
307
|
+
event.transition = transition;
|
|
308
|
+
applyTypes(transition, event.types);
|
|
309
|
+
if (event._skipped) {
|
|
310
|
+
skipTransition(transition);
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
for (const sub of phaseSubs.beforeSwap)
|
|
314
|
+
sub(event);
|
|
315
|
+
// Cold: the route suspended. Keep routing its content flushes into the
|
|
316
|
+
// transition until every route module has loaded (loadingDepth back to
|
|
317
|
+
// 0) — the page-level shell.
|
|
318
|
+
while (loadingDepth > 0) {
|
|
319
|
+
const contentProcess = await waitForColdFlush(myGen, COLD_COMMIT_TIMEOUT_MS);
|
|
320
|
+
if (navGen !== myGen)
|
|
321
|
+
return;
|
|
322
|
+
if (!contentProcess)
|
|
323
|
+
break; // timed out waiting
|
|
324
|
+
contentProcess();
|
|
325
|
+
}
|
|
326
|
+
// If the outgoing route had named elements but none has a FRESH partner
|
|
327
|
+
// in the new shell yet, the partner may load with the route's DATA
|
|
328
|
+
// (behind inner Suspense, which doesn't move loadingDepth — e.g. a list
|
|
329
|
+
// whose items come from a loader). Wait briefly for it so the morph can
|
|
330
|
+
// pair. "Fresh" ignores names that merely persisted on their original
|
|
331
|
+
// element (parent-layout chrome); otherwise such a name would satisfy
|
|
332
|
+
// the check immediately and the real partner would never be awaited.
|
|
333
|
+
if (oldNamed.size > 0 && !hasFreshMorphPartner(oldNamed)) {
|
|
334
|
+
while (!hasFreshMorphPartner(oldNamed)) {
|
|
335
|
+
const contentProcess = await waitForColdFlush(myGen, MORPH_PARTNER_GRACE_MS);
|
|
336
|
+
if (navGen !== myGen)
|
|
337
|
+
return;
|
|
338
|
+
if (!contentProcess)
|
|
339
|
+
break; // grace expired — capture as-is
|
|
340
|
+
contentProcess();
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (navGen !== myGen)
|
|
345
|
+
return;
|
|
346
|
+
fireAfterSwap(event);
|
|
347
|
+
transitionActive = false; // reached only when still current
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
// Non-conformant startViewTransition: just flush and fire the post phases.
|
|
352
|
+
transitionActive = false;
|
|
353
|
+
process();
|
|
354
|
+
const ev = buildEvent(from);
|
|
355
|
+
fireAfterSwap(ev);
|
|
356
|
+
fireAfterTransition(ev, 'unsupported');
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
transition.finished.then(() => {
|
|
360
|
+
if (event)
|
|
361
|
+
fireAfterTransition(event, event._skipped ? 'skipped' : undefined);
|
|
362
|
+
}, () => {
|
|
363
|
+
if (event)
|
|
364
|
+
fireAfterTransition(event, 'aborted');
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
// Synchronous route-change dispatch for an explicit `to`/`from`: fires
|
|
368
|
+
// `beforeTransition` and runs a transition that wraps a no-op swap (the route is
|
|
369
|
+
// assumed already on screen), firing the post-swap phases and applying types.
|
|
370
|
+
// Production navigations are driven by the scheduler (installNavTransition
|
|
371
|
+
// scheduler); this drives the same phase/type/lifecycle machinery directly for
|
|
372
|
+
// callers that change the route outside the normal navigation flow (and in unit
|
|
373
|
+
// tests).
|
|
374
|
+
export function __dispatchRouteChange(to, from) {
|
|
375
|
+
ensureDefaultTypes();
|
|
376
|
+
const event = new ViewTransitionEvent({
|
|
377
|
+
to,
|
|
378
|
+
from,
|
|
379
|
+
direction: getNavDirection(),
|
|
67
380
|
});
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
381
|
+
for (const sub of phaseSubs.beforeTransition)
|
|
382
|
+
sub(event);
|
|
383
|
+
const start = getStartViewTransition();
|
|
384
|
+
if (!start || event._skipped) {
|
|
385
|
+
// No transition runs, so `beforeSwap` (which precedes a real swap) is
|
|
386
|
+
// skipped; the post-swap phases still fire.
|
|
387
|
+
fireAfterSwap(event);
|
|
388
|
+
fireAfterTransition(event, event._skipped ? 'skipped' : 'unsupported');
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
let transition;
|
|
392
|
+
try {
|
|
393
|
+
transition = start(() => { });
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
fireAfterSwap(event);
|
|
397
|
+
fireAfterTransition(event, 'unsupported');
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
74
400
|
event.transition = transition;
|
|
401
|
+
applyTypes(transition, event.types);
|
|
75
402
|
for (const sub of phaseSubs.beforeSwap)
|
|
76
403
|
sub(event);
|
|
77
|
-
fireAfterSwap();
|
|
78
|
-
|
|
79
|
-
// have both run by this point, so the full set of types is available here.
|
|
80
|
-
const vtTypes = transition.types;
|
|
81
|
-
if (vtTypes && typeof vtTypes.add === 'function') {
|
|
82
|
-
for (const t of event.types)
|
|
83
|
-
vtTypes.add(t);
|
|
84
|
-
}
|
|
85
|
-
transition.finished.then(() => fireAfterTransition(), () => fireAfterTransition('aborted'));
|
|
404
|
+
fireAfterSwap(event);
|
|
405
|
+
transition.finished.then(() => fireAfterTransition(event), () => fireAfterTransition(event, 'aborted'));
|
|
86
406
|
}
|
|
87
407
|
let defaultTypesInstalled = false;
|
|
88
|
-
let
|
|
408
|
+
let firstNavSeen = false;
|
|
89
409
|
let defaultTypeUnsubscriber = null;
|
|
90
410
|
function ensureDefaultTypes() {
|
|
91
411
|
if (defaultTypesInstalled)
|
|
92
412
|
return;
|
|
93
413
|
defaultTypesInstalled = true;
|
|
94
414
|
defaultTypeUnsubscriber = __subscribePhase('beforeTransition', (event) => {
|
|
95
|
-
if (!
|
|
415
|
+
if (!firstNavSeen) {
|
|
96
416
|
event.types.push('nav-initial');
|
|
97
|
-
|
|
417
|
+
firstNavSeen = true;
|
|
98
418
|
}
|
|
99
419
|
else {
|
|
100
420
|
event.types.push(`nav-${event.direction}`);
|
|
@@ -108,6 +428,6 @@ export function resetDefaultTypesForTesting() {
|
|
|
108
428
|
defaultTypeUnsubscriber();
|
|
109
429
|
}
|
|
110
430
|
defaultTypesInstalled = false;
|
|
111
|
-
|
|
431
|
+
firstNavSeen = false;
|
|
112
432
|
defaultTypeUnsubscriber = null;
|
|
113
433
|
}
|
package/dist/iso/internal.d.ts
CHANGED
|
@@ -11,7 +11,7 @@ export { runRequestScope, getRequestStore, captureRequestScope, getActionResultS
|
|
|
11
11
|
export { default as wrapPromise } from './internal/wrap-promise.js';
|
|
12
12
|
export { HonoRequestContext } from './internal/contexts.js';
|
|
13
13
|
export { PageMiddlewareHost } from './internal/page-middleware-host.js';
|
|
14
|
-
export {
|
|
14
|
+
export { installNavTransitionScheduler, __subscribeRouteChange, } from './internal/route-change.js';
|
|
15
15
|
export { installHistoryShim, getNavDirection, } from './internal/history-shim.js';
|
|
16
16
|
export { __subscribePhase, type PhaseName } from './internal/route-change.js';
|
|
17
17
|
export { ViewTransitionEvent, type NavDirection, type ViewTransitionReason, } from './internal/view-transition-event.js';
|
package/dist/iso/internal.js
CHANGED
|
@@ -37,7 +37,7 @@ export { runRequestScope, getRequestStore, captureRequestScope, getActionResultS
|
|
|
37
37
|
export { default as wrapPromise } from './internal/wrap-promise.js';
|
|
38
38
|
export { HonoRequestContext } from './internal/contexts.js';
|
|
39
39
|
export { PageMiddlewareHost } from './internal/page-middleware-host.js';
|
|
40
|
-
export {
|
|
40
|
+
export { installNavTransitionScheduler, __subscribeRouteChange, } from './internal/route-change.js';
|
|
41
41
|
export { installHistoryShim, getNavDirection, } from './internal/history-shim.js';
|
|
42
42
|
export { __subscribePhase } from './internal/route-change.js';
|
|
43
43
|
export { ViewTransitionEvent, } from './internal/view-transition-event.js';
|
|
@@ -5,4 +5,15 @@ export interface ViewTransitionTypesNav {
|
|
|
5
5
|
direction: NavDirection;
|
|
6
6
|
}
|
|
7
7
|
export type ViewTransitionTypesInput = string | string[] | ((nav: ViewTransitionTypesNav) => string | string[] | null | undefined);
|
|
8
|
+
/**
|
|
9
|
+
* Register a global, route-aware view-transition type rule. The resolver runs on
|
|
10
|
+
* every navigation with `{ to, from, direction }` and returns the type(s) to add
|
|
11
|
+
* to that navigation's transition (a static string/array adds the same type(s) to
|
|
12
|
+
* every navigation). Returns an unsubscribe.
|
|
13
|
+
*
|
|
14
|
+
* Unlike {@link useViewTransitionTypes}, this is not tied to a mounted component,
|
|
15
|
+
* so it covers entering AND leaving a section (a layout hook is not subscribed yet
|
|
16
|
+
* on enter and is already torn down on leave). No-op on the server (no document).
|
|
17
|
+
*/
|
|
18
|
+
export declare function subscribeViewTransitionTypes(input: ViewTransitionTypesInput): () => void;
|
|
8
19
|
export declare function useViewTransitionTypes(input: ViewTransitionTypesInput): void;
|
|
@@ -1,21 +1,36 @@
|
|
|
1
1
|
import { useEffect, useRef } from 'preact/hooks';
|
|
2
2
|
import { __subscribePhase } from './internal/route-change.js';
|
|
3
|
+
/**
|
|
4
|
+
* Register a global, route-aware view-transition type rule. The resolver runs on
|
|
5
|
+
* every navigation with `{ to, from, direction }` and returns the type(s) to add
|
|
6
|
+
* to that navigation's transition (a static string/array adds the same type(s) to
|
|
7
|
+
* every navigation). Returns an unsubscribe.
|
|
8
|
+
*
|
|
9
|
+
* Unlike {@link useViewTransitionTypes}, this is not tied to a mounted component,
|
|
10
|
+
* so it covers entering AND leaving a section (a layout hook is not subscribed yet
|
|
11
|
+
* on enter and is already torn down on leave). No-op on the server (no document).
|
|
12
|
+
*/
|
|
13
|
+
export function subscribeViewTransitionTypes(input) {
|
|
14
|
+
if (typeof document === 'undefined')
|
|
15
|
+
return () => { };
|
|
16
|
+
return __subscribePhase('beforeTransition', (event) => {
|
|
17
|
+
const resolved = typeof input === 'function'
|
|
18
|
+
? input({ to: event.to, from: event.from, direction: event.direction })
|
|
19
|
+
: input;
|
|
20
|
+
if (resolved == null)
|
|
21
|
+
return;
|
|
22
|
+
if (typeof resolved === 'string')
|
|
23
|
+
event.types.push(resolved);
|
|
24
|
+
else
|
|
25
|
+
for (const t of resolved)
|
|
26
|
+
event.types.push(t);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
3
29
|
export function useViewTransitionTypes(input) {
|
|
4
30
|
const ref = useRef(input);
|
|
5
31
|
ref.current = input;
|
|
6
|
-
useEffect(() => {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
? v({ to: event.to, from: event.from, direction: event.direction })
|
|
11
|
-
: v;
|
|
12
|
-
if (resolved == null)
|
|
13
|
-
return;
|
|
14
|
-
if (typeof resolved === 'string')
|
|
15
|
-
event.types.push(resolved);
|
|
16
|
-
else
|
|
17
|
-
for (const t of resolved)
|
|
18
|
-
event.types.push(t);
|
|
19
|
-
});
|
|
20
|
-
}, []);
|
|
32
|
+
useEffect(() => subscribeViewTransitionTypes((nav) => {
|
|
33
|
+
const v = ref.current;
|
|
34
|
+
return typeof v === 'function' ? v(nav) : v;
|
|
35
|
+
}), []);
|
|
21
36
|
}
|
|
@@ -5,10 +5,11 @@ export function generateClientEntrySource(opts) {
|
|
|
5
5
|
return (`import { h, hydrate, render as renderPreact } from 'preact';\n` +
|
|
6
6
|
`import { LocationProvider } from 'preact-iso';\n` +
|
|
7
7
|
`import { Routes, PersistHost } from 'hono-preact';\n` +
|
|
8
|
-
`import {
|
|
8
|
+
`import { installNavTransitionScheduler, installStreamRegistry, installHistoryShim } from 'hono-preact/internal';\n` +
|
|
9
9
|
`import routes from '${opts.routesAbsPath}';\n` +
|
|
10
10
|
`\n` +
|
|
11
11
|
`installHistoryShim();\n` +
|
|
12
|
+
`installNavTransitionScheduler();\n` +
|
|
12
13
|
`installStreamRegistry();\n` +
|
|
13
14
|
`\n` +
|
|
14
15
|
`let persistHost = document.getElementById('__hp_persist_root');\n` +
|
|
@@ -19,16 +20,13 @@ export function generateClientEntrySource(opts) {
|
|
|
19
20
|
`}\n` +
|
|
20
21
|
`renderPreact(h(PersistHost, null), persistHost);\n` +
|
|
21
22
|
`\n` +
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
` __dispatchRouteChange(path, from);\n` +
|
|
27
|
-
`}\n` +
|
|
28
|
-
`\n` +
|
|
23
|
+
// View transitions are driven by installNavTransitionScheduler() above: it
|
|
24
|
+
// overrides Preact's render scheduler so a navigation's re-render runs inside
|
|
25
|
+
// document.startViewTransition (capturing the outgoing route as the old
|
|
26
|
+
// snapshot before the new one swaps in). No per-navigation wiring needed.
|
|
29
27
|
`hydrate(\n` +
|
|
30
28
|
` h(LocationProvider, null,\n` +
|
|
31
|
-
` h(Routes, { routes
|
|
29
|
+
` h(Routes, { routes })\n` +
|
|
32
30
|
` ),\n` +
|
|
33
31
|
` document.getElementById('app')\n` +
|
|
34
32
|
`);\n`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hono-preact",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Hono on the edge, Preact in the browser, manifest driven routes, typed RPC, streaming everywhere.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"hono",
|
|
@@ -99,6 +99,7 @@
|
|
|
99
99
|
"@hono-preact/iso": "0.1.0",
|
|
100
100
|
"@hono-preact/vite": "0.1.0"
|
|
101
101
|
},
|
|
102
|
+
"sideEffects": false,
|
|
102
103
|
"scripts": {
|
|
103
104
|
"build": "tsc && node scripts/consolidate.mjs",
|
|
104
105
|
"dev": "tsc --watch"
|