hono-preact 0.3.0 → 0.5.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.
@@ -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,19 @@
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
+ /**
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;
19
+ export declare function useViewTransitionTypes(input: ViewTransitionTypesInput): void;
@@ -0,0 +1,36 @@
1
+ import { useEffect, useRef } from 'preact/hooks';
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
+ }
29
+ export function useViewTransitionTypes(input) {
30
+ const ref = useRef(input);
31
+ ref.current = input;
32
+ useEffect(() => subscribeViewTransitionTypes((nav) => {
33
+ const v = ref.current;
34
+ return typeof v === 'function' ? v(nav) : v;
35
+ }), []);
36
+ }
@@ -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,24 +2,31 @@ 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 { installNavTransitionScheduler, installStreamRegistry, installHistoryShim } from 'hono-preact/internal';\n` +
9
9
  `import routes from '${opts.routesAbsPath}';\n` +
10
10
  `\n` +
11
+ `installHistoryShim();\n` +
12
+ `installNavTransitionScheduler();\n` +
11
13
  `installStreamRegistry();\n` +
12
14
  `\n` +
13
- `let lastPath;\n` +
14
- `function onRouteChange(path) {\n` +
15
- ` const from = lastPath;\n` +
16
- ` lastPath = path;\n` +
17
- ` __dispatchRouteChange(path, from);\n` +
15
+ `let persistHost = document.getElementById('__hp_persist_root');\n` +
16
+ `if (!persistHost) {\n` +
17
+ ` persistHost = document.createElement('div');\n` +
18
+ ` persistHost.id = '__hp_persist_root';\n` +
19
+ ` document.body.appendChild(persistHost);\n` +
18
20
  `}\n` +
21
+ `renderPreact(h(PersistHost, null), persistHost);\n` +
19
22
  `\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.
20
27
  `hydrate(\n` +
21
28
  ` h(LocationProvider, null,\n` +
22
- ` h(Routes, { routes, onRouteChange })\n` +
29
+ ` h(Routes, { routes })\n` +
23
30
  ` ),\n` +
24
31
  ` document.getElementById('app')\n` +
25
32
  `);\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hono-preact",
3
- "version": "0.3.0",
3
+ "version": "0.5.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",
@@ -95,8 +95,8 @@
95
95
  },
96
96
  "devDependencies": {
97
97
  "typescript": "*",
98
- "@hono-preact/server": "0.1.0",
99
98
  "@hono-preact/iso": "0.1.0",
99
+ "@hono-preact/server": "0.1.0",
100
100
  "@hono-preact/vite": "0.1.0"
101
101
  },
102
102
  "scripts": {