hono-preact 0.3.0 → 0.4.0

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.
@@ -36,3 +36,9 @@ export type { RouteChangeHandler } from './route-change.js';
36
36
  export { Head } from './head.js';
37
37
  export type { HeadProps } from './head.js';
38
38
  export { ClientScript } from './client-script.js';
39
+ export { useViewTransitionLifecycle, type ViewTransitionLifecycle, type ViewTransitionPhaseCallback, } from './view-transition-lifecycle.js';
40
+ export type { ViewTransitionEvent, NavDirection, ViewTransitionReason, } from './internal/view-transition-event.js';
41
+ export { useViewTransitionTypes, type ViewTransitionTypesInput, type ViewTransitionTypesNav, } from './view-transition-types.js';
42
+ export { getNavDirection as getViewTransitionDirection } from './internal/history-shim.js';
43
+ export { useViewTransitionName, useViewTransitionClass, ViewTransitionName, ViewTransitionGroup, type ViewTransitionNameProps, type ViewTransitionGroupProps, } from './view-transition-name.js';
44
+ export { Persist, PersistHost, type PersistProps } from './persist.js';
package/dist/iso/index.js CHANGED
@@ -32,3 +32,12 @@ export { isBrowser, env } from './is-browser.js';
32
32
  export { useRouteChange } from './route-change.js';
33
33
  export { Head } from './head.js';
34
34
  export { ClientScript } from './client-script.js';
35
+ // View transition lifecycle hook.
36
+ export { useViewTransitionLifecycle, } from './view-transition-lifecycle.js';
37
+ // View transitions types.
38
+ export { useViewTransitionTypes, } from './view-transition-types.js';
39
+ export { getNavDirection as getViewTransitionDirection } from './internal/history-shim.js';
40
+ // View transition name + group hooks and components.
41
+ export { useViewTransitionName, useViewTransitionClass, ViewTransitionName, ViewTransitionGroup, } from './view-transition-name.js';
42
+ // Persist components.
43
+ export { Persist, PersistHost } from './persist.js';
@@ -0,0 +1,7 @@
1
+ import type { NavDirection } from './view-transition-event.js';
2
+ export declare function installHistoryShim(): void;
3
+ export declare function getNavDirection(): NavDirection;
4
+ /** Test-only reset. Do not call from production code. */
5
+ export declare function resetHistoryShimForTesting(): void;
6
+ /** Test-only direction setter. Do not call from production code. */
7
+ export declare function setNavDirectionForTesting(dir: NavDirection): void;
@@ -0,0 +1,79 @@
1
+ let installed = false;
2
+ let counter = 0;
3
+ let lastDirection = 'initial';
4
+ let originalPush = null;
5
+ let originalReplace = null;
6
+ let popstateListener = null;
7
+ function readCounterFromState() {
8
+ if (typeof history === 'undefined')
9
+ return 0;
10
+ const state = history.state;
11
+ return state?.__hpVtIdx ?? 0;
12
+ }
13
+ export function installHistoryShim() {
14
+ if (installed)
15
+ return;
16
+ if (typeof history === 'undefined' || typeof window === 'undefined')
17
+ return;
18
+ installed = true;
19
+ counter = readCounterFromState();
20
+ lastDirection = 'initial';
21
+ originalPush = history.pushState.bind(history);
22
+ originalReplace = history.replaceState.bind(history);
23
+ history.pushState = function patchedPush(state, title, url) {
24
+ counter += 1;
25
+ const merged = {
26
+ ...(state ?? {}),
27
+ __hpVtIdx: counter,
28
+ };
29
+ originalPush(merged, title, url);
30
+ lastDirection = 'push';
31
+ };
32
+ history.replaceState = function patchedReplace(state, title, url) {
33
+ const merged = {
34
+ ...(state ?? {}),
35
+ __hpVtIdx: counter,
36
+ };
37
+ originalReplace(merged, title, url);
38
+ lastDirection = 'replace';
39
+ };
40
+ popstateListener = (e) => {
41
+ const incoming = e.state?.__hpVtIdx ?? 0;
42
+ lastDirection =
43
+ incoming < counter ? 'back' : incoming > counter ? 'forward' : 'replace';
44
+ counter = incoming;
45
+ };
46
+ window.addEventListener('popstate', popstateListener, { capture: true });
47
+ // Stamp the current entry so subsequent diffs are well-defined.
48
+ if (history.state?.__hpVtIdx === undefined) {
49
+ originalReplace({ ...(history.state ?? {}), __hpVtIdx: counter }, '');
50
+ }
51
+ }
52
+ export function getNavDirection() {
53
+ return lastDirection;
54
+ }
55
+ /** Test-only reset. Do not call from production code. */
56
+ export function resetHistoryShimForTesting() {
57
+ if (installed &&
58
+ typeof history !== 'undefined' &&
59
+ originalPush &&
60
+ originalReplace) {
61
+ history.pushState = originalPush;
62
+ history.replaceState = originalReplace;
63
+ }
64
+ if (typeof window !== 'undefined' && popstateListener) {
65
+ window.removeEventListener('popstate', popstateListener, {
66
+ capture: true,
67
+ });
68
+ }
69
+ installed = false;
70
+ counter = 0;
71
+ lastDirection = 'initial';
72
+ originalPush = null;
73
+ originalReplace = null;
74
+ popstateListener = null;
75
+ }
76
+ /** Test-only direction setter. Do not call from production code. */
77
+ export function setNavDirectionForTesting(dir) {
78
+ lastDirection = dir;
79
+ }
@@ -0,0 +1,4 @@
1
+ import type { Ref } from 'preact';
2
+ type AnyRef<T> = Ref<T> | null | undefined;
3
+ export declare function mergeRefs<T>(...refs: AnyRef<T>[]): (node: T | null) => void;
4
+ export {};
@@ -0,0 +1,14 @@
1
+ export function mergeRefs(...refs) {
2
+ return (node) => {
3
+ for (const ref of refs) {
4
+ if (ref == null)
5
+ continue;
6
+ if (typeof ref === 'function') {
7
+ ref(node);
8
+ }
9
+ else {
10
+ ref.current = node;
11
+ }
12
+ }
13
+ };
14
+ }
@@ -0,0 +1,10 @@
1
+ import type { ComponentChildren } from 'preact';
2
+ export interface PersistEntry {
3
+ children: ComponentChildren;
4
+ viewTransitionName: string | undefined;
5
+ }
6
+ export declare function __persistRegistryWrite(id: string, entry: PersistEntry): void;
7
+ export declare function __persistRegistryRead(): ReadonlyMap<string, PersistEntry>;
8
+ export declare function __persistRegistrySubscribe(sub: () => void): () => void;
9
+ /** Test-only reset. Do not call from production code. */
10
+ export declare function __persistRegistryResetForTesting(): void;
@@ -0,0 +1,24 @@
1
+ let map = new Map();
2
+ const subs = new Set();
3
+ export function __persistRegistryWrite(id, entry) {
4
+ // Replace the map reference so consumers using identity-checks can detect.
5
+ const next = new Map(map);
6
+ next.set(id, entry);
7
+ map = next;
8
+ for (const sub of subs)
9
+ sub();
10
+ }
11
+ export function __persistRegistryRead() {
12
+ return map;
13
+ }
14
+ export function __persistRegistrySubscribe(sub) {
15
+ subs.add(sub);
16
+ return () => {
17
+ subs.delete(sub);
18
+ };
19
+ }
20
+ /** Test-only reset. Do not call from production code. */
21
+ export function __persistRegistryResetForTesting() {
22
+ map = new Map();
23
+ subs.clear();
24
+ }
@@ -1,4 +1,10 @@
1
- type Sub = (to: string, from: string | undefined) => void;
1
+ import { ViewTransitionEvent } from './view-transition-event.js';
2
+ export type PhaseName = 'beforeTransition' | 'beforeSwap' | 'afterSwap' | 'afterTransition';
3
+ type PhaseSub = (event: ViewTransitionEvent) => void | Promise<void>;
4
+ type LegacySub = (to: string, from: string | undefined) => void;
5
+ export declare function __subscribePhase(phase: PhaseName, sub: PhaseSub): () => void;
6
+ export declare function __subscribeRouteChange(sub: LegacySub): () => void;
2
7
  export declare function __dispatchRouteChange(to: string, from: string | undefined): void;
3
- export declare function __subscribeRouteChange(sub: Sub): () => void;
8
+ /** @internal Test-only reset for default-types installer. */
9
+ export declare function resetDefaultTypesForTesting(): void;
4
10
  export {};
@@ -1,18 +1,113 @@
1
1
  import { flushSync } from 'preact/compat';
2
- const subs = new Set();
3
- export function __dispatchRouteChange(to, from) {
4
- for (const cb of subs)
5
- cb(to, from);
6
- if (typeof document === 'undefined')
7
- return;
8
- const startViewTransition = document.startViewTransition;
9
- if (typeof startViewTransition !== 'function')
10
- return;
11
- startViewTransition.call(document, () => flushSync(() => { }));
2
+ import { ViewTransitionEvent, } from './view-transition-event.js';
3
+ import { getNavDirection } from './history-shim.js';
4
+ const phaseSubs = {
5
+ beforeTransition: new Set(),
6
+ beforeSwap: new Set(),
7
+ afterSwap: new Set(),
8
+ afterTransition: new Set(),
9
+ };
10
+ const legacySubs = new Set();
11
+ export function __subscribePhase(phase, sub) {
12
+ phaseSubs[phase].add(sub);
13
+ return () => {
14
+ phaseSubs[phase].delete(sub);
15
+ };
12
16
  }
13
17
  export function __subscribeRouteChange(sub) {
14
- subs.add(sub);
18
+ legacySubs.add(sub);
15
19
  return () => {
16
- subs.delete(sub);
20
+ legacySubs.delete(sub);
21
+ };
22
+ }
23
+ function fireLegacy(to, from) {
24
+ for (const sub of legacySubs)
25
+ sub(to, from);
26
+ }
27
+ function getStartViewTransition() {
28
+ if (typeof document === 'undefined')
29
+ return undefined;
30
+ const fn = document.startViewTransition;
31
+ return typeof fn === 'function' ? fn.bind(document) : undefined;
32
+ }
33
+ export function __dispatchRouteChange(to, from) {
34
+ ensureDefaultTypes();
35
+ const direction = getNavDirection();
36
+ const event = new ViewTransitionEvent({ to, from, direction });
37
+ for (const sub of phaseSubs.beforeTransition)
38
+ sub(event);
39
+ const fireAfterSwap = () => {
40
+ for (const sub of phaseSubs.afterSwap)
41
+ sub(event);
42
+ // Legacy subscribers fire at the afterSwap slot: after the DOM swap,
43
+ // before the browser begins animating the new frame.
44
+ fireLegacy(to, from);
17
45
  };
46
+ const fireAfterTransition = (reason) => {
47
+ if (reason !== undefined)
48
+ event.reason = reason;
49
+ for (const sub of phaseSubs.afterTransition)
50
+ sub(event);
51
+ };
52
+ if (event._skipped) {
53
+ flushSync(() => { });
54
+ fireAfterSwap();
55
+ fireAfterTransition('skipped');
56
+ return;
57
+ }
58
+ const start = getStartViewTransition();
59
+ if (!start) {
60
+ flushSync(() => { });
61
+ fireAfterSwap();
62
+ fireAfterTransition('unsupported');
63
+ return;
64
+ }
65
+ const transition = start(() => {
66
+ flushSync(() => { });
67
+ });
68
+ // Set event.transition before firing beforeSwap so all subsequent phase
69
+ // subscribers see a non-null transition. In real browsers startViewTransition
70
+ // invokes the callback asynchronously, meaning transition is set here before
71
+ // the browser calls the update function. In synchronous test mocks the
72
+ // callback returns before start() does, so we set transition here (after
73
+ // start() returns) and then fire the post-swap phases manually.
74
+ event.transition = transition;
75
+ for (const sub of phaseSubs.beforeSwap)
76
+ sub(event);
77
+ fireAfterSwap();
78
+ // Apply types accumulated across all phases. beforeTransition and beforeSwap
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'));
86
+ }
87
+ let defaultTypesInstalled = false;
88
+ let firstDispatchSeen = false;
89
+ let defaultTypeUnsubscriber = null;
90
+ function ensureDefaultTypes() {
91
+ if (defaultTypesInstalled)
92
+ return;
93
+ defaultTypesInstalled = true;
94
+ defaultTypeUnsubscriber = __subscribePhase('beforeTransition', (event) => {
95
+ if (!firstDispatchSeen) {
96
+ event.types.push('nav-initial');
97
+ firstDispatchSeen = true;
98
+ }
99
+ else {
100
+ event.types.push(`nav-${event.direction}`);
101
+ }
102
+ event.types.push('nav-same-origin');
103
+ });
104
+ }
105
+ /** @internal Test-only reset for default-types installer. */
106
+ export function resetDefaultTypesForTesting() {
107
+ if (defaultTypeUnsubscriber) {
108
+ defaultTypeUnsubscriber();
109
+ }
110
+ defaultTypesInstalled = false;
111
+ firstDispatchSeen = false;
112
+ defaultTypeUnsubscriber = null;
18
113
  }
@@ -0,0 +1,11 @@
1
+ import { type ComponentChildren, type VNode } from 'preact';
2
+ type Props = Record<string, unknown>;
3
+ export type UseRenderRender = VNode | string | ((props: Props) => VNode) | undefined;
4
+ interface UseRenderOptions {
5
+ render?: UseRenderRender;
6
+ defaultTag: string;
7
+ props: Props;
8
+ children?: ComponentChildren;
9
+ }
10
+ export declare function useRender(opts: UseRenderOptions): VNode;
11
+ export {};
@@ -0,0 +1,47 @@
1
+ import { cloneElement, h, } from 'preact';
2
+ import { mergeRefs } from './merge-refs.js';
3
+ function joinClass(a, b) {
4
+ const parts = [];
5
+ if (typeof a === 'string' && a.length > 0)
6
+ parts.push(a);
7
+ if (typeof b === 'string' && b.length > 0)
8
+ parts.push(b);
9
+ if (parts.length === 0)
10
+ return undefined;
11
+ return parts.join(' ');
12
+ }
13
+ function mergeProps(user, framework) {
14
+ const out = { ...user };
15
+ for (const key of Object.keys(framework)) {
16
+ if (key === 'class' || key === 'className') {
17
+ const userClass = (user.class ?? user.className);
18
+ const merged = joinClass(userClass, framework[key]);
19
+ if (merged !== undefined)
20
+ out.class = merged;
21
+ delete out.className;
22
+ }
23
+ else if (key === 'ref') {
24
+ out.ref = mergeRefs(user.ref, framework.ref);
25
+ }
26
+ else {
27
+ out[key] = framework[key];
28
+ }
29
+ }
30
+ return out;
31
+ }
32
+ export function useRender(opts) {
33
+ const { render, defaultTag, props, children } = opts;
34
+ if (typeof render === 'function') {
35
+ return render(mergeProps({}, props));
36
+ }
37
+ if (render && typeof render === 'object' && 'type' in render) {
38
+ const merged = mergeProps((render.props ?? {}), props);
39
+ const mergedChildren = children !== undefined
40
+ ? children
41
+ : (render.props?.children ??
42
+ null);
43
+ return cloneElement(render, merged, mergedChildren);
44
+ }
45
+ const tag = typeof render === 'string' ? render : defaultTag;
46
+ return h(tag, props, children);
47
+ }
@@ -0,0 +1,23 @@
1
+ export type NavDirection = 'initial' | 'push' | 'replace' | 'back' | 'forward';
2
+ export type ViewTransitionReason = 'skipped' | 'unsupported' | 'aborted';
3
+ interface ViewTransitionEventInit {
4
+ to: string;
5
+ from: string | undefined;
6
+ direction: NavDirection;
7
+ }
8
+ export declare class ViewTransitionEvent {
9
+ readonly to: string;
10
+ readonly from: string | undefined;
11
+ readonly direction: NavDirection;
12
+ readonly types: string[];
13
+ transition: ViewTransition | null;
14
+ reason: ViewTransitionReason | undefined;
15
+ /** @internal */
16
+ _skipped: boolean;
17
+ private readonly stash;
18
+ constructor(init: ViewTransitionEventInit);
19
+ skip(): void;
20
+ set(key: unknown, value: unknown): void;
21
+ get<T = unknown>(key: unknown): T | undefined;
22
+ }
23
+ export {};
@@ -0,0 +1,25 @@
1
+ export class ViewTransitionEvent {
2
+ to;
3
+ from;
4
+ direction;
5
+ types = [];
6
+ transition = null;
7
+ reason = undefined;
8
+ /** @internal */
9
+ _skipped = false;
10
+ stash = new Map();
11
+ constructor(init) {
12
+ this.to = init.to;
13
+ this.from = init.from;
14
+ this.direction = init.direction;
15
+ }
16
+ skip() {
17
+ this._skipped = true;
18
+ }
19
+ set(key, value) {
20
+ this.stash.set(key, value);
21
+ }
22
+ get(key) {
23
+ return this.stash.get(key);
24
+ }
25
+ }
@@ -12,6 +12,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
14
  export { __dispatchRouteChange, __subscribeRouteChange, } from './internal/route-change.js';
15
+ export { installHistoryShim, getNavDirection, } from './internal/history-shim.js';
16
+ export { __subscribePhase, type PhaseName } from './internal/route-change.js';
17
+ export { ViewTransitionEvent, type NavDirection, type ViewTransitionReason, } from './internal/view-transition-event.js';
18
+ export { useRender, type UseRenderRender } from './internal/use-render.js';
19
+ export { mergeRefs } from './internal/merge-refs.js';
15
20
  export { installStreamRegistry, subscribeToLoaderStream, } from './internal/stream-registry.js';
16
21
  export { registerServerStreamingLoader, takeServerStreamingLoaders, } from './internal/streaming-ssr.js';
17
22
  export type { ServerLoaderStream } from './internal/streaming-ssr.js';
@@ -38,6 +38,11 @@ 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
40
  export { __dispatchRouteChange, __subscribeRouteChange, } from './internal/route-change.js';
41
+ export { installHistoryShim, getNavDirection, } from './internal/history-shim.js';
42
+ export { __subscribePhase } from './internal/route-change.js';
43
+ export { ViewTransitionEvent, } from './internal/view-transition-event.js';
44
+ export { useRender } from './internal/use-render.js';
45
+ export { mergeRefs } from './internal/merge-refs.js';
41
46
  export { installStreamRegistry, subscribeToLoaderStream, } from './internal/stream-registry.js';
42
47
  export { registerServerStreamingLoader, takeServerStreamingLoaders, } from './internal/streaming-ssr.js';
43
48
  export { beginSubmit, endSubmit, isPending, subscribe as subscribeFormSubmit, } from './internal/form-submit-store.js';
@@ -0,0 +1,14 @@
1
+ import type { ComponentChildren, VNode } from 'preact';
2
+ export interface PersistProps {
3
+ id: string;
4
+ viewTransitionName?: string;
5
+ children?: ComponentChildren;
6
+ }
7
+ export declare function Persist(props: PersistProps): VNode;
8
+ export declare namespace Persist {
9
+ var displayName: string;
10
+ }
11
+ export declare function PersistHost(): VNode;
12
+ export declare namespace PersistHost {
13
+ var displayName: string;
14
+ }
@@ -0,0 +1,56 @@
1
+ import { jsx as _jsx } from "preact/jsx-runtime";
2
+ import { Fragment, h } from 'preact';
3
+ import { useLayoutEffect, useReducer } from 'preact/hooks';
4
+ import { __persistRegistryWrite, __persistRegistryRead, __persistRegistrySubscribe, } from './internal/persist-registry.js';
5
+ import { useViewTransitionName } from './view-transition-name.js';
6
+ import { isBrowser } from './is-browser.js';
7
+ export function Persist(props) {
8
+ const browser = isBrowser();
9
+ // Hook is called unconditionally. The effect short-circuits on the server,
10
+ // so SSR's render output (children inline) remains the only side effect.
11
+ // No deps array: runs after every render so children/viewTransitionName
12
+ // updates flow through without stale captures.
13
+ useLayoutEffect(() => {
14
+ if (!browser)
15
+ return;
16
+ const entry = {
17
+ children: props.children,
18
+ viewTransitionName: props.viewTransitionName,
19
+ };
20
+ __persistRegistryWrite(props.id, entry);
21
+ // Intentionally no cleanup: Persist does NOT clear the registry on unmount.
22
+ // Keeping the last-known children lets PersistHost continue to render
23
+ // across route changes where Persist temporarily disappears.
24
+ });
25
+ // SSR renders children inline so first paint matches steady state;
26
+ // the client renders nothing inline because PersistHost owns the DOM.
27
+ return browser ? h(Fragment, null) : h(Fragment, null, props.children);
28
+ }
29
+ Persist.displayName = 'Persist';
30
+ function PersistSlot(props) {
31
+ const ref = useViewTransitionName(props.entry.viewTransitionName);
32
+ return (_jsx("div", { "data-hp-persist-slot": props.id, ref: ref, children: props.entry.children }));
33
+ }
34
+ PersistSlot.displayName = 'PersistSlot';
35
+ export function PersistHost() {
36
+ // useReducer instead of useState: guarantees a re-render on each dispatch
37
+ // even if an intermediate render has already drained the "new" state.
38
+ // This matters for the ordering race: Persist's useLayoutEffect may run
39
+ // either before or after PersistHost's. Using useReducer ensures the
40
+ // forced tick after subscribe always queues a fresh render regardless of
41
+ // React/Preact batching.
42
+ const [, forceUpdate] = useReducer((x) => x + 1, 0);
43
+ // useLayoutEffect (not useEffect) so the subscription is in place before
44
+ // sibling effects run. The immediate forceUpdate after subscribe re-reads
45
+ // the registry to catch any Persist sibling whose useLayoutEffect already
46
+ // wrote between PersistHost's render and this subscribe call (sibling order
47
+ // is render order, but effect order can differ per host).
48
+ useLayoutEffect(() => {
49
+ const unsub = __persistRegistrySubscribe(() => forceUpdate(undefined));
50
+ forceUpdate(undefined);
51
+ return unsub;
52
+ }, []);
53
+ const map = __persistRegistryRead();
54
+ return (_jsx(Fragment, { children: Array.from(map.entries()).map(([id, entry]) => (_jsx(PersistSlot, { id: id, entry: entry }, id))) }));
55
+ }
56
+ PersistHost.displayName = 'PersistHost';
@@ -0,0 +1,9 @@
1
+ import type { ViewTransitionEvent } from './internal/view-transition-event.js';
2
+ export type ViewTransitionPhaseCallback = (event: ViewTransitionEvent) => void | Promise<void>;
3
+ export interface ViewTransitionLifecycle {
4
+ onBeforeTransition?: ViewTransitionPhaseCallback;
5
+ onBeforeSwap?: ViewTransitionPhaseCallback;
6
+ onAfterSwap?: ViewTransitionPhaseCallback;
7
+ onAfterTransition?: ViewTransitionPhaseCallback;
8
+ }
9
+ export declare function useViewTransitionLifecycle(lifecycle: ViewTransitionLifecycle): void;
@@ -0,0 +1,18 @@
1
+ import { useEffect, useRef } from 'preact/hooks';
2
+ import { __subscribePhase } from './internal/route-change.js';
3
+ export function useViewTransitionLifecycle(lifecycle) {
4
+ const ref = useRef(lifecycle);
5
+ ref.current = lifecycle;
6
+ useEffect(() => {
7
+ const unsubs = [
8
+ __subscribePhase('beforeTransition', (e) => ref.current.onBeforeTransition?.(e)),
9
+ __subscribePhase('beforeSwap', (e) => ref.current.onBeforeSwap?.(e)),
10
+ __subscribePhase('afterSwap', (e) => ref.current.onAfterSwap?.(e)),
11
+ __subscribePhase('afterTransition', (e) => ref.current.onAfterTransition?.(e)),
12
+ ];
13
+ return () => {
14
+ for (const u of unsubs)
15
+ u();
16
+ };
17
+ }, []);
18
+ }
@@ -0,0 +1,17 @@
1
+ import type { ComponentChildren, VNode } from 'preact';
2
+ import { type UseRenderRender } from './internal/use-render.js';
3
+ export declare function useViewTransitionName(name: string | null | undefined): (node: Element | null) => void;
4
+ export declare function useViewTransitionClass(cls: string | string[] | null | undefined): (node: Element | null) => void;
5
+ export interface ViewTransitionNameProps {
6
+ name: string | null | undefined;
7
+ groupClass?: string | string[];
8
+ render?: UseRenderRender;
9
+ children?: ComponentChildren;
10
+ }
11
+ export declare function ViewTransitionName(props: ViewTransitionNameProps): VNode;
12
+ export interface ViewTransitionGroupProps {
13
+ class: string | string[];
14
+ render?: UseRenderRender;
15
+ children?: ComponentChildren;
16
+ }
17
+ export declare function ViewTransitionGroup(props: ViewTransitionGroupProps): VNode;
@@ -0,0 +1,79 @@
1
+ import { useCallback, useLayoutEffect, useRef } from 'preact/hooks';
2
+ import { mergeRefs } from './internal/merge-refs.js';
3
+ import { useRender } from './internal/use-render.js';
4
+ function isStyledElement(node) {
5
+ return (node !== null && (node instanceof HTMLElement || node instanceof SVGElement));
6
+ }
7
+ function applyCssProp(node, property, value) {
8
+ if (!node)
9
+ return;
10
+ if (value == null || value === '') {
11
+ node.style.removeProperty(property);
12
+ }
13
+ else {
14
+ node.style.setProperty(property, value);
15
+ }
16
+ }
17
+ export function useViewTransitionName(name) {
18
+ const nodeRef = useRef(null);
19
+ const nameRef = useRef(name);
20
+ nameRef.current = name;
21
+ // Sync when name changes on a node we already hold.
22
+ useLayoutEffect(() => {
23
+ applyCssProp(nodeRef.current, 'view-transition-name', name);
24
+ }, [name]);
25
+ // Stable ref callback: applies on attach, clears the previous node on swap.
26
+ return useCallback((node) => {
27
+ if (nodeRef.current && nodeRef.current !== node) {
28
+ nodeRef.current.style.removeProperty('view-transition-name');
29
+ }
30
+ if (isStyledElement(node)) {
31
+ nodeRef.current = node;
32
+ applyCssProp(node, 'view-transition-name', nameRef.current);
33
+ }
34
+ else {
35
+ nodeRef.current = null;
36
+ }
37
+ }, []);
38
+ }
39
+ export function useViewTransitionClass(cls) {
40
+ const value = cls == null ? null : Array.isArray(cls) ? cls.join(' ') : cls;
41
+ const nodeRef = useRef(null);
42
+ const valueRef = useRef(value);
43
+ valueRef.current = value;
44
+ useLayoutEffect(() => {
45
+ applyCssProp(nodeRef.current, 'view-transition-class', value);
46
+ }, [value]);
47
+ return useCallback((node) => {
48
+ if (nodeRef.current && nodeRef.current !== node) {
49
+ nodeRef.current.style.removeProperty('view-transition-class');
50
+ }
51
+ if (isStyledElement(node)) {
52
+ nodeRef.current = node;
53
+ applyCssProp(node, 'view-transition-class', valueRef.current);
54
+ }
55
+ else {
56
+ nodeRef.current = null;
57
+ }
58
+ }, []);
59
+ }
60
+ export function ViewTransitionName(props) {
61
+ const nameRef = useViewTransitionName(props.name);
62
+ const classRef = useViewTransitionClass(props.groupClass);
63
+ const ref = mergeRefs(nameRef, classRef);
64
+ return useRender({
65
+ render: props.render,
66
+ defaultTag: 'div',
67
+ props: { ref },
68
+ children: props.children,
69
+ });
70
+ }
71
+ export function ViewTransitionGroup(props) {
72
+ const classRef = useViewTransitionClass(props.class);
73
+ return useRender({
74
+ render: props.render,
75
+ defaultTag: 'div',
76
+ props: { ref: classRef },
77
+ children: props.children,
78
+ });
79
+ }
@@ -0,0 +1,8 @@
1
+ import type { NavDirection } from './internal/view-transition-event.js';
2
+ export interface ViewTransitionTypesNav {
3
+ to: string;
4
+ from: string | undefined;
5
+ direction: NavDirection;
6
+ }
7
+ export type ViewTransitionTypesInput = string | string[] | ((nav: ViewTransitionTypesNav) => string | string[] | null | undefined);
8
+ export declare function useViewTransitionTypes(input: ViewTransitionTypesInput): void;
@@ -0,0 +1,21 @@
1
+ import { useEffect, useRef } from 'preact/hooks';
2
+ import { __subscribePhase } from './internal/route-change.js';
3
+ export function useViewTransitionTypes(input) {
4
+ const ref = useRef(input);
5
+ ref.current = input;
6
+ useEffect(() => {
7
+ return __subscribePhase('beforeTransition', (event) => {
8
+ const v = ref.current;
9
+ const resolved = typeof v === 'function'
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
+ }, []);
21
+ }
@@ -221,65 +221,105 @@ export async function renderPage(c, node, options) {
221
221
  // throw and, for the per-loader catch, get logged as a synthetic error
222
222
  // chunk that nobody can read anyway).
223
223
  let aborted = false;
224
- const responseStream = new ReadableStream({
225
- start(controller) {
226
- // Re-enter the captured request scope so generator continuations and
227
- // anything they touch (e.g. `getRequestHonoContext`, per-request loader
228
- // caches) see the same per-request store the initial prerender saw.
229
- return bindRequestScope(async () => {
224
+ // Multi-producer backpressure via TransformStream. Each loader pump writes
225
+ // to a shared `writer`, awaiting `writer.ready` before each write so that
226
+ // the per-loader iteration is paced by the consumer's read rate (not by
227
+ // however fast the generator yields). Without this, a tight-loop streaming
228
+ // loader would buffer chunks into the ReadableStream queue unbounded; see
229
+ // render-stream.test.tsx "pauses production when the HTML consumer is
230
+ // slow (backpressure)".
231
+ const { writable, readable: responseStream } = new TransformStream();
232
+ const writer = writable.getWriter();
233
+ // When the consumer cancels the readable side (e.g. Hono drops the response,
234
+ // or the runtime tears down the request), the writable side transitions to
235
+ // an errored state and `writer.closed` rejects. Propagate to the loader
236
+ // generators symmetrically with the request-signal abort path below. The
237
+ // `aborted` guard makes the self-triggered case (`writer.abort()` in our
238
+ // own finally) a no-op.
239
+ writer.closed.catch(() => {
240
+ if (aborted)
241
+ return;
242
+ aborted = true;
243
+ for (const { gen } of streamingLoaders) {
244
+ gen.return(undefined).catch(() => {
245
+ /* swallow */
246
+ });
247
+ }
248
+ });
249
+ // Re-enter the captured request scope so generator continuations and
250
+ // anything they touch (e.g. `getRequestHonoContext`, per-request loader
251
+ // caches) see the same per-request store the initial prerender saw.
252
+ void bindRequestScope(async () => {
253
+ // Yield one microtask before doing anything else. `renderPage` is still
254
+ // on the synchronous frame that constructs this response (TransformStream
255
+ // is created, then `c.body(...)` runs and commits the headers). Resuming
256
+ // a generator can call `setCookie(ctx.c, ...)`, which mutates Hono's
257
+ // prepared headers; deferring the pump guarantees the response is built
258
+ // first, so post-first-yield header writes are consistently excluded
259
+ // rather than racing construction. Cookies must be set before the
260
+ // loader's first yield to reach the streamed response.
261
+ await Promise.resolve();
262
+ try {
263
+ if (aborted)
264
+ return;
265
+ await writer.ready;
266
+ if (aborted)
267
+ return;
268
+ await writer.write(encoder.encode(`<!doctype html>${beforeBody}\n${bootstrap}\n`));
269
+ // Drive each pending generator in parallel; emit script tags per chunk.
270
+ await Promise.all(streamingLoaders.map(async ({ loaderId, gen }) => {
230
271
  try {
231
- if (aborted)
232
- return;
233
- controller.enqueue(encoder.encode(`<!doctype html>${beforeBody}\n${bootstrap}\n`));
234
- // Yield one microtask before advancing any loader generator past
235
- // its first yield. `renderPage` is still on the synchronous frame
236
- // that constructs this response (`new ReadableStream(...)` returns,
237
- // then `c.body(...)` runs and commits the headers). Resuming a
238
- // generator can call `setCookie(ctx.c, ...)`, which mutates Hono's
239
- // prepared headers; deferring the pump guarantees the response is
240
- // built first, so post-first-yield header writes are consistently
241
- // excluded rather than racing construction. Cookies must be set
242
- // before the loader's first yield to reach the streamed response.
243
- await Promise.resolve();
244
- // Drive each pending generator in parallel; emit script tags per chunk.
245
- await Promise.all(streamingLoaders.map(async ({ loaderId, gen }) => {
246
- try {
247
- while (!aborted) {
248
- const step = await gen.next();
249
- if (aborted)
250
- return;
251
- if (step.done) {
252
- controller.enqueue(encoder.encode(`<script>window.__HP_STREAM__.end(${jsonForScript(loaderId)});document.currentScript.remove()</script>\n`));
253
- return;
254
- }
255
- controller.enqueue(encoder.encode(`<script>window.__HP_STREAM__.push(${jsonForScript(loaderId)},${jsonForScript(step.value)});document.currentScript.remove()</script>\n`));
256
- }
257
- }
258
- catch (err) {
259
- if (aborted)
260
- return;
261
- const message = err instanceof Error ? err.message : String(err);
262
- const name = err instanceof Error ? err.name : 'Error';
263
- controller.enqueue(encoder.encode(`<script>window.__HP_STREAM__.error(${jsonForScript(loaderId)},${jsonForScript({ message, name })});document.currentScript.remove()</script>\n`));
272
+ while (!aborted) {
273
+ const step = await gen.next();
274
+ if (aborted)
275
+ return;
276
+ await writer.ready;
277
+ if (aborted)
278
+ return;
279
+ if (step.done) {
280
+ await writer.write(encoder.encode(`<script>window.__HP_STREAM__.end(${jsonForScript(loaderId)});document.currentScript.remove()</script>\n`));
281
+ return;
264
282
  }
265
- }));
266
- if (!aborted)
267
- controller.enqueue(encoder.encode(afterBody));
283
+ await writer.write(encoder.encode(`<script>window.__HP_STREAM__.push(${jsonForScript(loaderId)},${jsonForScript(step.value)});document.currentScript.remove()</script>\n`));
284
+ }
268
285
  }
269
- finally {
270
- if (!aborted)
271
- controller.close();
286
+ catch (err) {
287
+ if (aborted)
288
+ return;
289
+ const message = err instanceof Error ? err.message : String(err);
290
+ const name = err instanceof Error ? err.name : 'Error';
291
+ try {
292
+ await writer.ready;
293
+ if (aborted)
294
+ return;
295
+ await writer.write(encoder.encode(`<script>window.__HP_STREAM__.error(${jsonForScript(loaderId)},${jsonForScript({ message, name })});document.currentScript.remove()</script>\n`));
296
+ }
297
+ catch {
298
+ /* swallow: writable side closed/errored */
299
+ }
272
300
  }
273
- });
274
- },
275
- cancel() {
276
- aborted = true;
277
- for (const { gen } of streamingLoaders) {
278
- gen.return(undefined).catch(() => {
301
+ }));
302
+ if (!aborted) {
303
+ await writer.ready;
304
+ if (!aborted)
305
+ await writer.write(encoder.encode(afterBody));
306
+ }
307
+ }
308
+ catch {
309
+ /* swallow: writable side closed/errored mid-pump */
310
+ }
311
+ finally {
312
+ if (aborted) {
313
+ writer.abort().catch(() => {
279
314
  /* swallow */
280
315
  });
281
316
  }
282
- },
317
+ else {
318
+ writer.close().catch(() => {
319
+ /* swallow */
320
+ });
321
+ }
322
+ }
283
323
  });
284
324
  requestSignal.addEventListener('abort', () => {
285
325
  aborted = true;
@@ -288,6 +328,9 @@ export async function renderPage(c, node, options) {
288
328
  /* swallow */
289
329
  });
290
330
  }
331
+ writer.abort().catch(() => {
332
+ /* swallow */
333
+ });
291
334
  });
292
335
  // Route through `c.body()` rather than `new Response(...)` so Hono merges
293
336
  // its prepared headers into the streamed response. A streaming loader's
@@ -2,14 +2,23 @@ import * as path from 'node:path';
2
2
  export const VIRTUAL_CLIENT_ENTRY_ID = 'virtual:hono-preact/client';
3
3
  const RESOLVED_ID = '\0' + VIRTUAL_CLIENT_ENTRY_ID;
4
4
  export function generateClientEntrySource(opts) {
5
- return (`import { h, hydrate } from 'preact';\n` +
5
+ return (`import { h, hydrate, render as renderPreact } from 'preact';\n` +
6
6
  `import { LocationProvider } from 'preact-iso';\n` +
7
- `import { Routes } from 'hono-preact';\n` +
8
- `import { __dispatchRouteChange, installStreamRegistry } from 'hono-preact/internal';\n` +
7
+ `import { Routes, PersistHost } from 'hono-preact';\n` +
8
+ `import { __dispatchRouteChange, installStreamRegistry, installHistoryShim } from 'hono-preact/internal';\n` +
9
9
  `import routes from '${opts.routesAbsPath}';\n` +
10
10
  `\n` +
11
+ `installHistoryShim();\n` +
11
12
  `installStreamRegistry();\n` +
12
13
  `\n` +
14
+ `let persistHost = document.getElementById('__hp_persist_root');\n` +
15
+ `if (!persistHost) {\n` +
16
+ ` persistHost = document.createElement('div');\n` +
17
+ ` persistHost.id = '__hp_persist_root';\n` +
18
+ ` document.body.appendChild(persistHost);\n` +
19
+ `}\n` +
20
+ `renderPreact(h(PersistHost, null), persistHost);\n` +
21
+ `\n` +
13
22
  `let lastPath;\n` +
14
23
  `function onRouteChange(path) {\n` +
15
24
  ` const from = lastPath;\n` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hono-preact",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Hono on the edge, Preact in the browser, manifest driven routes, typed RPC, streaming everywhere.",
5
5
  "keywords": [
6
6
  "hono",