@wular/pnext 0.0.2 → 0.0.4
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/README.md +76 -20
- package/package.json +3 -2
- package/reference/data/bench.json +513 -0
- package/reference/performance.md +75 -48
- package/src/api/router/runtime.ts +60 -21
- package/src/cache/context.ts +4 -1
- package/src/cli/build.ts +37 -5
- package/src/cli/dev.ts +6 -0
- package/src/cli/index.ts +17 -3
- package/src/cli/request-pipeline.ts +1340 -0
- package/src/cli/server-entry.ts +180 -0
- package/src/cli/start.ts +39 -1311
- package/src/client/build.ts +59 -20
- package/src/client/chunk-fold.ts +40 -0
- package/src/client/compat-surface.ts +175 -0
- package/src/client/entry.ts +67 -52
- package/src/compat/actions/action-client.ts +8 -1
- package/src/compat/actions/action-dispatch.ts +11 -1
- package/src/compat/actions/discovery.ts +23 -6
- package/src/compat/bundler/optimize-package-imports.ts +5 -1
- package/src/compat/bundler/worker.ts +2 -1
- package/src/compat/client/errors/bare-boundary.ts +32 -0
- package/src/compat/client/errors/error-boundary.ts +1 -15
- package/src/compat/client/errors/primitive-throw.ts +16 -0
- package/src/compat/client/link-status.ts +1 -1
- package/src/compat/css/lightningcss.ts +2 -1
- package/src/compat/css/modules.ts +4 -3
- package/src/compat/lifecycle/instrumentation-client.ts +1 -1
- package/src/compat/lifecycle/instrumentation.ts +5 -2
- package/src/compat/next/config-loader.ts +33 -5
- package/src/compat/next/dynamic.tsx +9 -5
- package/src/compat/next/link-validation-transform.ts +5 -1
- package/src/compat/next/link.tsx +51 -58
- package/src/compat/pages/client-plugin.ts +2 -1
- package/src/compat/react/action-state.ts +159 -0
- package/src/compat/react/client-lite.ts +74 -0
- package/src/compat/react/hooks-extra.ts +92 -0
- package/src/compat/react/parity.ts +128 -0
- package/src/compat/react/preact.ts +33 -420
- package/src/compat/react/server-inserted-html.ts +14 -7
- package/src/compat/react/use.ts +72 -0
- package/src/compat/register/actions.ts +27 -7
- package/src/compat/register/segment.ts +16 -6
- package/src/config.ts +15 -1
- package/src/css/build.ts +13 -2
- package/src/dev/imports.ts +34 -5
- package/src/dev/module-cache.ts +19 -0
- package/src/dev/module-transform.ts +7 -1
- package/src/dev/server.ts +91 -22
- package/src/dynamic/source.ts +36 -27
- package/src/ppr.ts +5 -4
- package/src/proxy.ts +5 -1
- package/src/render/island-context.ts +21 -3
- package/src/render/renderer.ts +102 -26
- package/src/resolve/engine.ts +12 -2
- package/src/resolve/scan-facts.ts +239 -1
- package/src/routing/href.ts +4 -5
- package/src/routing/routes.ts +26 -41
- package/src/runtime/server.ts +8 -4
- package/src/runtime/vendor.ts +1 -1
- package/src/typegen.ts +3 -3
- package/src/utils/esbuild.ts +58 -0
- package/src/utils/fs.ts +14 -2
- package/src/utils/native-require.ts +28 -0
|
@@ -6,7 +6,11 @@
|
|
|
6
6
|
// build-time-only dependency that also shipped into the deployed function. The gate is
|
|
7
7
|
// module-record-shaped (does this file import next/link at all), so the AST is only ever materialized
|
|
8
8
|
// for a file that really uses one.
|
|
9
|
-
|
|
9
|
+
// Lazy: the oxc-parser native binding costs ~12.6 MB RSS; load it only when a parse happens.
|
|
10
|
+
const parseSync: typeof import('oxc-parser').parseSync = (...args) =>
|
|
11
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
12
|
+
loadNative(() => require('oxc-parser') as typeof import('oxc-parser')).parseSync(...args);
|
|
13
|
+
import { loadNative } from '../../utils/native-require';
|
|
10
14
|
import { rewriteFacts } from '../../resolve/scan-facts';
|
|
11
15
|
|
|
12
16
|
const marker = '__pnextLegacyChildKind';
|
package/src/compat/next/link.tsx
CHANGED
|
@@ -1,18 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
useRef,
|
|
9
|
-
useState,
|
|
10
|
-
type MutableRefObject,
|
|
11
|
-
type ReactElement,
|
|
12
|
-
type ReactNode,
|
|
13
|
-
type Ref,
|
|
14
|
-
} from 'preact/compat';
|
|
15
|
-
import type { JSX } from 'preact';
|
|
1
|
+
// preact core + hooks only (no preact/compat): this module rides in every compat app's first-page
|
|
2
|
+
// bundle, and the lite client tier must not pull compat through it. Children.only/count are replaced
|
|
3
|
+
// with toChildArray below; the ref arrives React-19-style as a prop (the parity vnode pass moves
|
|
4
|
+
// vnode.ref into props for function components).
|
|
5
|
+
import { cloneElement, h as createElement, toChildArray, type JSX } from 'preact';
|
|
6
|
+
import { useContext, useEffect, useRef, useState } from 'preact/hooks';
|
|
7
|
+
import type { MutableRefObject, ReactElement, ReactNode, Ref } from 'react';
|
|
16
8
|
import { usePrefetchLifecycle } from '../../api/link';
|
|
17
9
|
import { blockJavascriptUrl, isJavascriptUrl } from '../../api/router';
|
|
18
10
|
import { applyTrailingSlash, routeHref, type SearchInput } from '../../routing/href';
|
|
@@ -78,28 +70,28 @@ export type LinkProps = Omit<JSX.HTMLAttributes<HTMLAnchorElement>, 'href' | 'on
|
|
|
78
70
|
children?: ReactNode;
|
|
79
71
|
};
|
|
80
72
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
) {
|
|
73
|
+
// Ref arrives as a prop (React 19 style): the parity vnode pass moves vnode.ref into props for plain
|
|
74
|
+
// function components, on both server and client, so forwardRef is unnecessary.
|
|
75
|
+
function Link({
|
|
76
|
+
href,
|
|
77
|
+
as,
|
|
78
|
+
prefetch = null,
|
|
79
|
+
unstable_dynamicOnHover,
|
|
80
|
+
replace,
|
|
81
|
+
scroll,
|
|
82
|
+
locale: _locale,
|
|
83
|
+
legacyBehavior,
|
|
84
|
+
passHref,
|
|
85
|
+
onClick,
|
|
86
|
+
onNavigate,
|
|
87
|
+
transitionTypes,
|
|
88
|
+
onPrefetchStart,
|
|
89
|
+
onPrefetchFinish,
|
|
90
|
+
__pnextLegacyChildKind,
|
|
91
|
+
children,
|
|
92
|
+
ref: forwardedRef,
|
|
93
|
+
...anchorProps
|
|
94
|
+
}: LinkProps) {
|
|
103
95
|
// `as` supersedes `href` for what the anchor actually points at (app router).
|
|
104
96
|
const target = as == null ? href : as;
|
|
105
97
|
// basePath: prefix in-app hrefs so the rendered anchor (and the soft-nav it
|
|
@@ -206,7 +198,7 @@ const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link(
|
|
|
206
198
|
anchorAttributes,
|
|
207
199
|
createElement(LinkStatusContext.Provider, { value: { pending: linkPending(statusToken) } }, children),
|
|
208
200
|
) as ReactElement;
|
|
209
|
-
}
|
|
201
|
+
}
|
|
210
202
|
|
|
211
203
|
export default Link;
|
|
212
204
|
export { Link };
|
|
@@ -241,6 +233,10 @@ function renderLegacyChild(
|
|
|
241
233
|
const isDev = process.env.NODE_ENV !== 'production';
|
|
242
234
|
const isServer = !process.browser && typeof window === 'undefined';
|
|
243
235
|
|
|
236
|
+
// A React.lazy payload passed directly (not wrapped in JSX) never resolves to
|
|
237
|
+
// a single element child; Next reports it specifically instead of Children.only.
|
|
238
|
+
if (isLazyComponent(children)) throw unsupportedLegacyChildError();
|
|
239
|
+
|
|
244
240
|
if (children == null || children === false) {
|
|
245
241
|
if (isDev) {
|
|
246
242
|
throw validationError(
|
|
@@ -253,10 +249,10 @@ function renderLegacyChild(
|
|
|
253
249
|
|
|
254
250
|
// A bare string/number child renders its own <a> (Next's legacy behavior).
|
|
255
251
|
if (typeof children === 'string' || typeof children === 'number') {
|
|
256
|
-
return createElement('a', { ...anchorAttributes, href: resolvedHref }, children);
|
|
252
|
+
return createElement('a', { ...anchorAttributes, href: resolvedHref }, children) as unknown as ReactElement;
|
|
257
253
|
}
|
|
258
254
|
|
|
259
|
-
const count =
|
|
255
|
+
const count = toChildArray(children as JSX.Element).length;
|
|
260
256
|
if (count > 1) {
|
|
261
257
|
if (isDev) {
|
|
262
258
|
throw validationError(
|
|
@@ -267,7 +263,7 @@ function renderLegacyChild(
|
|
|
267
263
|
return childrenOnlyOrThrow(children);
|
|
268
264
|
}
|
|
269
265
|
|
|
270
|
-
const child =
|
|
266
|
+
const child = childrenOnlyOrThrow(children) as ReactElement<Record<string, unknown>>;
|
|
271
267
|
const kind =
|
|
272
268
|
annotatedKind ??
|
|
273
269
|
(isAsyncFunctionComponent(child.type)
|
|
@@ -313,7 +309,7 @@ function renderLegacyChild(
|
|
|
313
309
|
childProps['data-pnext-replace'] = anchorAttributes['data-pnext-replace'];
|
|
314
310
|
childProps['data-pnext-scroll'] = anchorAttributes['data-pnext-scroll'];
|
|
315
311
|
}
|
|
316
|
-
return cloneElement(child, childProps);
|
|
312
|
+
return cloneElement(child as never, childProps) as ReactElement;
|
|
317
313
|
}
|
|
318
314
|
|
|
319
315
|
function reportUnsupportedServerChild(kind: LegacyChildKind, isDev: boolean) {
|
|
@@ -353,17 +349,16 @@ function validationError(message: string, digest: string) {
|
|
|
353
349
|
return Object.assign(new Error(message), { digest });
|
|
354
350
|
}
|
|
355
351
|
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
// builds (no dev-mode validation) fall through to native Children.only
|
|
359
|
-
// semantics for the no-children/multiple-children cases, so reproduce React's
|
|
360
|
-
// exact wording here rather than surfacing preact's own throw.
|
|
352
|
+
// React.Children.only semantics on preact core: exactly one element child, with
|
|
353
|
+
// React's exact error wording for the no-children/multiple-children cases.
|
|
361
354
|
function childrenOnlyOrThrow(children: ReactNode): ReactElement {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
355
|
+
const array = toChildArray(children as JSX.Element);
|
|
356
|
+
if (array.length === 1 && isValidElementLike(array[0])) return array[0] as ReactElement;
|
|
357
|
+
throw new Error('React.Children.only expected to receive a single React element child.');
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function isValidElementLike(value: unknown) {
|
|
361
|
+
return value !== null && typeof value === 'object' && 'type' in value;
|
|
367
362
|
}
|
|
368
363
|
|
|
369
364
|
function isAsyncFunctionComponent(type: unknown) {
|
|
@@ -373,11 +368,9 @@ function isAsyncFunctionComponent(type: unknown) {
|
|
|
373
368
|
}
|
|
374
369
|
|
|
375
370
|
function isLazyComponent(type: unknown) {
|
|
376
|
-
return
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
(type as { $$typeof?: unknown }).$$typeof === Symbol.for('react.lazy')
|
|
380
|
-
);
|
|
371
|
+
if (type === null || (typeof type !== 'object' && typeof type !== 'function')) return false;
|
|
372
|
+
const marked = type as { $$typeof?: unknown } & Record<symbol, unknown>;
|
|
373
|
+
return marked.$$typeof === Symbol.for('react.lazy') || marked[Symbol.for('pnext.lazy')] === true;
|
|
381
374
|
}
|
|
382
375
|
|
|
383
376
|
function isServerComponent(type: unknown) {
|
|
@@ -413,7 +406,7 @@ function assignRef(ref: Ref<HTMLAnchorElement> | undefined, node: HTMLAnchorElem
|
|
|
413
406
|
ref(node);
|
|
414
407
|
return;
|
|
415
408
|
}
|
|
416
|
-
|
|
409
|
+
ref.current = node;
|
|
417
410
|
}
|
|
418
411
|
|
|
419
412
|
function hrefString(href: string | URL | UrlObject) {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import {
|
|
4
|
+
import type { Loader, OnLoadResult, Plugin } from 'esbuild';
|
|
5
|
+
import { build as esbuild } from '../../utils/esbuild';
|
|
5
6
|
import type { ResolvedConfig } from '../../config';
|
|
6
7
|
import { applyClientSourceTransforms, getBundlerExtensions } from '../../extensions';
|
|
7
8
|
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from 'preact/hooks';
|
|
2
|
+
import { hooksUsable } from './hooks-extra';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* React 19's useActionState, on preact hooks. Returns [state, dispatch, isPending]; dispatch(payload)
|
|
6
|
+
* runs `action(prevState, payload)` (async ok) and swaps the state when it settles. The returned dispatch
|
|
7
|
+
* is also valid as a `<form action={...}>` value - the pnext client runtime intercepts function form
|
|
8
|
+
* actions and calls them with the form's FormData. Queueing follows React: concurrent dispatches chain
|
|
9
|
+
* in order against the latest settled state rather than racing.
|
|
10
|
+
*/
|
|
11
|
+
export function useActionState<State, Payload = FormData>(
|
|
12
|
+
action: (state: Awaited<State>, payload: Payload) => State | Promise<State>,
|
|
13
|
+
initialState: Awaited<State>,
|
|
14
|
+
_permalink?: string,
|
|
15
|
+
): [state: Awaited<State>, dispatch: (payload: Payload) => void, isPending: boolean] {
|
|
16
|
+
// Server resolve may invoke a client component as a plain function (no
|
|
17
|
+
// preact render context, so hooks throw). SSR output for useActionState is
|
|
18
|
+
// always the (possibly progressively-updated) initial state with a dispatch
|
|
19
|
+
// that only works after hydration; fall back to exactly that. The dispatch
|
|
20
|
+
// carries form-state metadata so SSR can progressively enhance
|
|
21
|
+
// <form action={dispatch}>.
|
|
22
|
+
if (!hooksUsable()) {
|
|
23
|
+
const staticInitial = (consumeActionStateOverride() ?? { value: initialState })
|
|
24
|
+
.value as Awaited<State>;
|
|
25
|
+
const staticDispatch = (payload: Payload) =>
|
|
26
|
+
void Promise.resolve(action(staticInitial, payload));
|
|
27
|
+
tagFormStateDispatch(staticDispatch, action, staticInitial, _permalink);
|
|
28
|
+
return [staticInitial, staticDispatch, false];
|
|
29
|
+
}
|
|
30
|
+
const initialRef = useRef<{ value: Awaited<State> } | null>(null);
|
|
31
|
+
if (!initialRef.current) {
|
|
32
|
+
// A progressive (no-JS) submission re-renders the page with the action's
|
|
33
|
+
// result as the form state; the server (and the inline hydration script)
|
|
34
|
+
// publish it via a consumed-once global override.
|
|
35
|
+
initialRef.current = {
|
|
36
|
+
value: (consumeActionStateOverride() ?? { value: initialState }).value as Awaited<State>,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const [state, setState] = useState<Awaited<State>>(initialRef.current.value);
|
|
40
|
+
const [pending, setPending] = useState(0);
|
|
41
|
+
const lastSettled = useRef<Awaited<State>>(initialRef.current.value);
|
|
42
|
+
const chain = useRef<Promise<unknown>>(Promise.resolve());
|
|
43
|
+
// A rejected action must surface to the nearest error boundary during an actual preact render (only
|
|
44
|
+
// diff() wraps component calls in the getDerivedStateFromError/componentDidCatch try/catch).
|
|
45
|
+
// preact/hooks' setState invokes a functional updater EAGERLY at call time, to bail out on an unchanged
|
|
46
|
+
// value, rather than deferring it to the render - so `setState(() => { throw error })` from inside an
|
|
47
|
+
// async .catch handler throws immediately in that microtask, outside any render call stack and outside
|
|
48
|
+
// any try/catch, producing an unhandled rejection instead of reaching the boundary. Stash the error in
|
|
49
|
+
// a ref and force a re-render instead; the throw then happens inside this hook's own render call.
|
|
50
|
+
const pendingError = useRef<{ error: unknown } | null>(null);
|
|
51
|
+
const [, forceRender] = useState(0);
|
|
52
|
+
|
|
53
|
+
const dispatch = useCallback(
|
|
54
|
+
(payload: Payload) => {
|
|
55
|
+
setPending(count => count + 1);
|
|
56
|
+
// Chain in dispatch order against the latest settled state. A rejected
|
|
57
|
+
// action leaves the previous state in place (matching React, where the
|
|
58
|
+
// error propagates to the nearest error boundary via the transition) and
|
|
59
|
+
// must not poison the queue for later dispatches.
|
|
60
|
+
chain.current = chain.current.then(async () => {
|
|
61
|
+
try {
|
|
62
|
+
const redirectsBefore = actionRedirectCount();
|
|
63
|
+
const next = (await action(lastSettled.current, payload));
|
|
64
|
+
// A redirect renders the destination's initial form state even when
|
|
65
|
+
// the shared layout island itself survives the navigation.
|
|
66
|
+
if (next === undefined && actionRedirectCount() !== redirectsBefore) {
|
|
67
|
+
lastSettled.current = initialRef.current!.value;
|
|
68
|
+
setState(() => initialRef.current!.value);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
lastSettled.current = next;
|
|
72
|
+
setState(() => next);
|
|
73
|
+
} finally {
|
|
74
|
+
setPending(count => count - 1);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
// React propagates action errors to the nearest error boundary (they
|
|
78
|
+
// are not catchable at the dispatch site). Stash it and force a render:
|
|
79
|
+
// a class error boundary in the tree catches the throw below; without
|
|
80
|
+
// one the uncaught render error reaches the window 'error' event, where
|
|
81
|
+
// the compat entry's error.js overlay picks it up.
|
|
82
|
+
chain.current = chain.current.catch(error => {
|
|
83
|
+
if (!process.browser && typeof window === 'undefined') return;
|
|
84
|
+
pendingError.current = { error };
|
|
85
|
+
forceRender(count => count + 1);
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
[action],
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
if (pendingError.current) {
|
|
92
|
+
const { error } = pendingError.current;
|
|
93
|
+
pendingError.current = null;
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
tagFormStateDispatch(dispatch, action, state, _permalink);
|
|
98
|
+
return [state, dispatch, pending > 0];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Form-state metadata attached to a useActionState dispatch so the server
|
|
103
|
+
* renderer can progressively enhance <form action={dispatch}>: the underlying
|
|
104
|
+
* action (for its wire id), the state at render time (posted back in a hidden
|
|
105
|
+
* field so the server can run `action(state, formData)` without JS), and the
|
|
106
|
+
* optional permalink target.
|
|
107
|
+
*/
|
|
108
|
+
export interface FormStateDispatchMeta {
|
|
109
|
+
action: (state: never, payload: never) => unknown;
|
|
110
|
+
state: unknown;
|
|
111
|
+
permalink?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function tagFormStateDispatch(
|
|
115
|
+
dispatch: (payload: never) => void,
|
|
116
|
+
action: (state: never, payload: never) => unknown,
|
|
117
|
+
state: unknown,
|
|
118
|
+
permalink?: string,
|
|
119
|
+
) {
|
|
120
|
+
(dispatch as unknown as { $$pnextFormState?: FormStateDispatchMeta }).$$pnextFormState = {
|
|
121
|
+
action: action,
|
|
122
|
+
state,
|
|
123
|
+
...(permalink !== undefined ? { permalink } : {}),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Consumed-once initial-state override for useActionState, published either by
|
|
129
|
+
* the server before re-rendering a page for a progressive form submission, or
|
|
130
|
+
* by the inline hydration script that mirrors it to the client.
|
|
131
|
+
*/
|
|
132
|
+
/** Redirect counter the action-client runtime bumps (see markActionRedirected). */
|
|
133
|
+
function actionRedirectCount(): number {
|
|
134
|
+
return (globalThis as { __pnextActionRedirects?: number }).__pnextActionRedirects ?? 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function consumeActionStateOverride(): { value: unknown } | undefined {
|
|
138
|
+
const holder = globalThis as { __PNEXT_ACTION_STATE__?: unknown };
|
|
139
|
+
if (!('__PNEXT_ACTION_STATE__' in holder)) return undefined;
|
|
140
|
+
const override = holder.__PNEXT_ACTION_STATE__;
|
|
141
|
+
if (
|
|
142
|
+
override !== null &&
|
|
143
|
+
typeof override === 'object' &&
|
|
144
|
+
'skip' in override &&
|
|
145
|
+
typeof override.skip === 'number' &&
|
|
146
|
+
override.skip > 0
|
|
147
|
+
) {
|
|
148
|
+
override.skip--;
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
delete holder.__PNEXT_ACTION_STATE__;
|
|
152
|
+
return {
|
|
153
|
+
value:
|
|
154
|
+
override !== null && typeof override === 'object' && 'value' in override
|
|
155
|
+
? override.value
|
|
156
|
+
: override,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Lite client `react` alias for compat apps whose client graph provably never suspends and only uses
|
|
2
|
+
// the core-preact-equivalent React surface (see clientCompatSurface in src/client/compat-surface.ts).
|
|
3
|
+
// Everything here is backed by preact core + preact/hooks, so the bundle ships no preact/compat. The
|
|
4
|
+
// parity import wires the same vnode pass (React 19 ref prop, primitive-throw safety) as the full shim.
|
|
5
|
+
import './parity';
|
|
6
|
+
import {
|
|
7
|
+
Component,
|
|
8
|
+
Fragment,
|
|
9
|
+
cloneElement,
|
|
10
|
+
createContext,
|
|
11
|
+
createElement,
|
|
12
|
+
createRef,
|
|
13
|
+
isValidElement,
|
|
14
|
+
} from 'preact';
|
|
15
|
+
import {
|
|
16
|
+
useCallback,
|
|
17
|
+
useContext,
|
|
18
|
+
useDebugValue,
|
|
19
|
+
useEffect,
|
|
20
|
+
useId,
|
|
21
|
+
useImperativeHandle,
|
|
22
|
+
useLayoutEffect,
|
|
23
|
+
useMemo,
|
|
24
|
+
useReducer,
|
|
25
|
+
useRef,
|
|
26
|
+
useState,
|
|
27
|
+
} from 'preact/hooks';
|
|
28
|
+
import { useOptimistic, useTransition } from './hooks-extra';
|
|
29
|
+
import { useActionState } from './action-state';
|
|
30
|
+
|
|
31
|
+
export {
|
|
32
|
+
Component,
|
|
33
|
+
Fragment,
|
|
34
|
+
cloneElement,
|
|
35
|
+
createContext,
|
|
36
|
+
createElement,
|
|
37
|
+
createRef,
|
|
38
|
+
isValidElement,
|
|
39
|
+
useCallback,
|
|
40
|
+
useContext,
|
|
41
|
+
useDebugValue,
|
|
42
|
+
useEffect,
|
|
43
|
+
useId,
|
|
44
|
+
useImperativeHandle,
|
|
45
|
+
useLayoutEffect,
|
|
46
|
+
useMemo,
|
|
47
|
+
useReducer,
|
|
48
|
+
useRef,
|
|
49
|
+
useState,
|
|
50
|
+
useActionState,
|
|
51
|
+
useOptimistic,
|
|
52
|
+
useTransition,
|
|
53
|
+
};
|
|
54
|
+
export { ViewTransition, addTransitionType } from './view-transition';
|
|
55
|
+
|
|
56
|
+
// Same passthrough semantics preact/compat gives these.
|
|
57
|
+
export const StrictMode = Fragment;
|
|
58
|
+
export const startTransition = (callback: () => void) => callback();
|
|
59
|
+
export const useDeferredValue = <T>(value: T): T => value;
|
|
60
|
+
export const useInsertionEffect = useLayoutEffect;
|
|
61
|
+
export const version = '19.0.0';
|
|
62
|
+
|
|
63
|
+
export function cache<Args extends unknown[], Result>(
|
|
64
|
+
fn: (...args: Args) => Result,
|
|
65
|
+
): (...args: Args) => Result {
|
|
66
|
+
return (...args: Args) => fn(...args);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function cacheSignal() {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// No default export: the surface scan already forces the full tier for any app doing
|
|
74
|
+
// `import React from 'react'`, and a default object here would pin every export against treeshaking.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { options, type VNode } from 'preact';
|
|
2
|
+
import { useCallback, useRef, useState } from 'preact/hooks';
|
|
3
|
+
|
|
4
|
+
// React 19 hooks (use, useActionState, useOptimistic, useTransition) implemented directly on
|
|
5
|
+
// preact/hooks so a suspense-free client bundle can ship them without preact/compat. The full shim
|
|
6
|
+
// re-exports these same functions - one implementation, both surfaces.
|
|
7
|
+
|
|
8
|
+
// True when a preact hooks call would succeed (a component render is in flight). preact fires
|
|
9
|
+
// options._render just before invoking a component and options.diffed after it commits; between the two,
|
|
10
|
+
// hooks have a current component. Server-side tree resolution invokes some client components as plain
|
|
11
|
+
// functions, outside any preact render, where hooks would throw - useActionState/useOptimistic fall back
|
|
12
|
+
// to SSR-equivalent static values there instead.
|
|
13
|
+
let renderInFlight = false;
|
|
14
|
+
|
|
15
|
+
export function hooksUsable(): boolean {
|
|
16
|
+
return renderInFlight;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
{
|
|
20
|
+
const anyOptions = options as unknown as Record<string, unknown>;
|
|
21
|
+
const previousRender = anyOptions.__r as ((vnode: VNode) => void) | undefined;
|
|
22
|
+
anyOptions.__r = (vnode: VNode) => {
|
|
23
|
+
renderInFlight = true;
|
|
24
|
+
previousRender?.(vnode);
|
|
25
|
+
};
|
|
26
|
+
const previousDiffed = options.diffed?.bind(options);
|
|
27
|
+
options.diffed = vnode => {
|
|
28
|
+
renderInFlight = false;
|
|
29
|
+
previousDiffed?.(vnode);
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* React 19's useTransition, on preact hooks. preact/compat's own useTransition is a no-op stub that never
|
|
35
|
+
* tracks pending state, so an async transition callback's in-flight window is invisible. Track it with a
|
|
36
|
+
* counter: startTransition(cb) runs cb, and when cb returns a thenable holds `isPending` true until it
|
|
37
|
+
* settles. Concurrent transitions overlap, each incrementing and decrementing, so isPending stays true
|
|
38
|
+
* while ANY is in flight - matching React.
|
|
39
|
+
*/
|
|
40
|
+
export function useTransition(): [boolean, (callback: () => void | Promise<void>) => void] {
|
|
41
|
+
if (!hooksUsable()) return [false, callback => void callback()];
|
|
42
|
+
const [pending, setPending] = useState(0);
|
|
43
|
+
const startTransition = useCallback((callback: () => void | Promise<void>) => {
|
|
44
|
+
const mpaBefore = (globalThis as { __pnextMpaNavigation?: number }).__pnextMpaNavigation ?? 0;
|
|
45
|
+
let result: void | Promise<void>;
|
|
46
|
+
try {
|
|
47
|
+
result = callback();
|
|
48
|
+
} catch {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const mpaAfter = (globalThis as { __pnextMpaNavigation?: number }).__pnextMpaNavigation ?? 0;
|
|
52
|
+
if (mpaAfter !== mpaBefore) {
|
|
53
|
+
setPending(count => count + 1);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (result && typeof (result as PromiseLike<unknown>).then === 'function') {
|
|
57
|
+
setPending(count => count + 1);
|
|
58
|
+
void Promise.resolve(result).finally(() => setPending(count => count - 1));
|
|
59
|
+
}
|
|
60
|
+
}, []);
|
|
61
|
+
return [pending > 0, startTransition];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* React 19's useOptimistic, on preact hooks (simplified: the optimistic value
|
|
66
|
+
* resets whenever the passthrough (base) value changes on a rerender, which is
|
|
67
|
+
* when the real state has caught up).
|
|
68
|
+
*/
|
|
69
|
+
export function useOptimistic<State, Payload = State>(
|
|
70
|
+
passthrough: State,
|
|
71
|
+
reducer?: (state: State, payload: Payload) => State,
|
|
72
|
+
): [State, (payload: Payload) => void] {
|
|
73
|
+
if (!hooksUsable()) return [passthrough, () => undefined];
|
|
74
|
+
const [optimistic, setOptimistic] = useState<{ value: State } | null>(null);
|
|
75
|
+
const lastBase = useRef(passthrough);
|
|
76
|
+
if (lastBase.current !== passthrough) {
|
|
77
|
+
lastBase.current = passthrough;
|
|
78
|
+
if (optimistic) setOptimistic(null);
|
|
79
|
+
}
|
|
80
|
+
const dispatch = useCallback(
|
|
81
|
+
(payload: Payload) => {
|
|
82
|
+
setOptimistic(current => ({
|
|
83
|
+
value: reducer
|
|
84
|
+
? reducer(current ? current.value : lastBase.current, payload)
|
|
85
|
+
: (payload as unknown as State),
|
|
86
|
+
}));
|
|
87
|
+
},
|
|
88
|
+
[reducer],
|
|
89
|
+
);
|
|
90
|
+
return [optimistic ? optimistic.value : passthrough, dispatch];
|
|
91
|
+
}
|
|
92
|
+
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { options, type ComponentType, type VNode } from 'preact';
|
|
2
|
+
import { wrapComponentForPrimitiveThrows } from '../client/errors/primitive-throw';
|
|
3
|
+
|
|
4
|
+
const reactForwardRefSymbol = Symbol.for('react.forward_ref');
|
|
5
|
+
const react19RefCompatInstalled = Symbol.for('pnext.react19-ref-compat-installed');
|
|
6
|
+
const reactTextSeparatorSymbol = Symbol.for('pnext.react-text-separator');
|
|
7
|
+
|
|
8
|
+
type PNextPreactOptions = typeof options & {
|
|
9
|
+
[react19RefCompatInstalled]?: true;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type RefCompatibleComponent = ComponentType<Record<string, unknown>> & {
|
|
13
|
+
$$typeof?: symbol;
|
|
14
|
+
prototype?: { render?: unknown };
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// preact's `options.vnode` is process-global and cannot be uninstalled, so its BEHAVIOR follows the
|
|
18
|
+
// active extension host instead: one server process can serve a compat app and then a pure-core one, and
|
|
19
|
+
// the core app must not inherit React parity it never asked for. Defaults to on - importing this module
|
|
20
|
+
// IS compat being wired, and the client never resets.
|
|
21
|
+
let reactCompatActive = true;
|
|
22
|
+
|
|
23
|
+
/** Enable/disable the react-compat vnode parity pass (see reactCompatActive). */
|
|
24
|
+
export function setReactCompatActive(active: boolean): void {
|
|
25
|
+
reactCompatActive = active;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Suspense-dependent parity (thenable children, async client components) needs preact/compat, which a
|
|
29
|
+
// suspense-free client bundle never ships. The full shim registers it on import; the lite path leaves
|
|
30
|
+
// the slot empty and its bundle drops preact/compat entirely. Two phases: async-component wrapping must
|
|
31
|
+
// see the raw component (before primitive-throw wraps it); thenable children run after.
|
|
32
|
+
interface SuspenseParity {
|
|
33
|
+
beforeThrowSafety(vnode: VNode): void;
|
|
34
|
+
afterThrowSafety(vnode: VNode): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let suspenseParity: SuspenseParity | undefined;
|
|
38
|
+
|
|
39
|
+
export function setSuspenseParity(pass: SuspenseParity): void {
|
|
40
|
+
suspenseParity = pass;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const pnextOptions = options as PNextPreactOptions;
|
|
44
|
+
if (!pnextOptions[react19RefCompatInstalled]) {
|
|
45
|
+
const previousVNode = options.vnode?.bind(options);
|
|
46
|
+
options.vnode = vnode => {
|
|
47
|
+
previousVNode?.(vnode);
|
|
48
|
+
if (!reactCompatActive) return;
|
|
49
|
+
applyReact19RefProp(vnode);
|
|
50
|
+
suspenseParity?.beforeThrowSafety(vnode);
|
|
51
|
+
applyPrimitiveThrowSafety(vnode);
|
|
52
|
+
suspenseParity?.afterThrowSafety(vnode);
|
|
53
|
+
applyReactTextSeparators(vnode);
|
|
54
|
+
};
|
|
55
|
+
pnextOptions[react19RefCompatInstalled] = true;
|
|
56
|
+
|
|
57
|
+
// Effect scheduling parity with React: preact/hooks flushes passive effects
|
|
58
|
+
// via requestAnimationFrame + setTimeout(35) (afterNextFrame), both governed
|
|
59
|
+
// by the page clock. React flushes them through its scheduler's
|
|
60
|
+
// MessageChannel, which fake-timer installations (Playwright's CDP clock in
|
|
61
|
+
// Next's own e2e suites) never intercept. Under an installed fake clock the
|
|
62
|
+
// rAF path loses its "before the next idle callback" ordering and
|
|
63
|
+
// Suspense-boundary promotions land after a test's post-drain DOM sample.
|
|
64
|
+
// Route the flush through a MessageChannel task so effect timing is
|
|
65
|
+
// clock-independent, like React's.
|
|
66
|
+
if ((process.browser || typeof window !== 'undefined') && typeof MessageChannel !== 'undefined') {
|
|
67
|
+
const queue: (() => void)[] = [];
|
|
68
|
+
const channel = new MessageChannel();
|
|
69
|
+
channel.port1.onmessage = () => {
|
|
70
|
+
for (const callback of queue.splice(0)) callback();
|
|
71
|
+
};
|
|
72
|
+
(options as { requestAnimationFrame?: (cb: () => void) => void }).requestAnimationFrame =
|
|
73
|
+
callback => {
|
|
74
|
+
queue.push(callback);
|
|
75
|
+
channel.port2.postMessage(null);
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function applyReactTextSeparators(vnode: VNode) {
|
|
81
|
+
// Server-only. `process.browser` is a client-bundle define (true) and
|
|
82
|
+
// undefined on the server, so this body folds out of browser builds; the
|
|
83
|
+
// `typeof window` twin keeps the runtime check for unbundled consumers.
|
|
84
|
+
if (process.browser || typeof window !== 'undefined') return;
|
|
85
|
+
const props = vnode.props as Record<string, unknown>;
|
|
86
|
+
const children = props.children;
|
|
87
|
+
if (!Array.isArray(children) || children.length < 2) return;
|
|
88
|
+
const values = children as unknown[];
|
|
89
|
+
|
|
90
|
+
const separated: unknown[] = [];
|
|
91
|
+
let changed = false;
|
|
92
|
+
for (let index = 0; index < values.length; index += 1) {
|
|
93
|
+
const child = values[index];
|
|
94
|
+
separated.push(child);
|
|
95
|
+
if (!mayRenderText(child) || !mayRenderText(values[index + 1])) continue;
|
|
96
|
+
separated.push({
|
|
97
|
+
type: reactTextSeparatorSymbol,
|
|
98
|
+
props: { UNSTABLE_comment: ' ' },
|
|
99
|
+
});
|
|
100
|
+
changed = true;
|
|
101
|
+
}
|
|
102
|
+
if (changed) props.children = separated;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function mayRenderText(value: unknown) {
|
|
106
|
+
if (typeof value === 'string' || typeof value === 'number') return true;
|
|
107
|
+
if (!value || typeof value !== 'object' || !('type' in value)) return false;
|
|
108
|
+
return typeof (value as VNode).type !== 'string';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function applyPrimitiveThrowSafety(vnode: VNode) {
|
|
112
|
+
// Client-only: this exists to stop a thrown undefined/null from crashing preact's OWN diff.js `e.then`
|
|
113
|
+
// suspense check during real preact diffing. The server's resolveServerTree is a custom RSC-style tree
|
|
114
|
+
// walker, not preact's diff - it never hits that crash, and wrapping component references there risks
|
|
115
|
+
// losing the symbol-keyed metadata resolveServerTree branches on.
|
|
116
|
+
if (!process.browser && typeof window === 'undefined') return;
|
|
117
|
+
if (typeof vnode.type !== 'function') return;
|
|
118
|
+
vnode.type = wrapComponentForPrimitiveThrows(vnode.type);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function applyReact19RefProp(vnode: VNode) {
|
|
122
|
+
if (!vnode.ref || typeof vnode.type !== 'function') return;
|
|
123
|
+
const component = vnode.type as RefCompatibleComponent;
|
|
124
|
+
if (component.prototype?.render || component.$$typeof === reactForwardRefSymbol) return;
|
|
125
|
+
|
|
126
|
+
vnode.props = { ...(vnode.props as Record<string, unknown>), ref: vnode.ref } as unknown as typeof vnode.props;
|
|
127
|
+
vnode.ref = null;
|
|
128
|
+
}
|