@wular/pnext 0.0.20 → 0.0.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli/build/cache.ts +324 -0
- package/src/cli/build.ts +19 -3
- package/src/cli/create.ts +4 -4
- package/src/cli/migrate/package-json.ts +3 -2
- package/src/cli/serve/entry.ts +14 -4
- package/src/cli/serve/pipeline.ts +4 -0
- package/src/cli/start.ts +18 -11
- package/src/cli/typegen.ts +4 -1
- package/src/client/build.ts +13 -10
- package/src/client/entry.ts +201 -12
- package/src/client/router/runtime.ts +20 -6
- package/src/compat/metadata.ts +37 -4
- package/src/compat/next/config-loader.ts +7 -1
- package/src/compat/register/proxy.ts +3 -1
- package/src/compat/register/typed-routes.ts +3 -1
- package/src/compat/typecheck/check.ts +5 -1
- package/src/compat/validation/validate.ts +2 -0
- package/src/config.ts +7 -0
- package/src/dev/server.ts +2 -0
- package/src/extensions.ts +7 -1
- package/src/render/metadata.ts +53 -31
- package/src/render/renderer.ts +3 -2
- package/src/resolve/imports.ts +7 -1
- package/src/resolve/scan-facts.ts +7 -1
- package/src/runtime/fetch-host.ts +46 -2
- package/src/types.ts +84 -67
- package/src/utils/registry.ts +70 -0
package/src/client/entry.ts
CHANGED
|
@@ -139,6 +139,142 @@ function islandVNodeExpr(
|
|
|
139
139
|
: `h(${componentExpr}, ${propsExpr}, ${childrenExpr})`
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* A streamed Suspense hole is the one region a live island stops owning: `$RC` replaces the hole's
|
|
144
|
+
* children wholesale before the graft's materialize pass turns the arriving island markers into
|
|
145
|
+
* hosts. Rendering the hole through this component leaves a seam that pass can re-adopt through -
|
|
146
|
+
* INSIDE the parent island's tree, so the new islands keep the parent's whole context chain. Mounting
|
|
147
|
+
* such a host as its own preact root instead severs it from every provider the parent renders.
|
|
148
|
+
*/
|
|
149
|
+
function holeSeamSource() {
|
|
150
|
+
return `
|
|
151
|
+
class PnextHole extends Component {
|
|
152
|
+
componentDidMount() {
|
|
153
|
+
if (this.base) this.base.__pnextHole = this;
|
|
154
|
+
}
|
|
155
|
+
componentWillUnmount() {
|
|
156
|
+
if (this.base && this.base.__pnextHole === this) this.base.__pnextHole = undefined;
|
|
157
|
+
}
|
|
158
|
+
componentWillReceiveProps(next) {
|
|
159
|
+
// A soft navigation re-adopts the parent from the incoming document's DOM;
|
|
160
|
+
// the previous document's grafted content must not outlive its children.
|
|
161
|
+
if (next.children !== this.props.children) this.setState({ adopted: undefined });
|
|
162
|
+
}
|
|
163
|
+
render(props, state) {
|
|
164
|
+
return h('pnext-hole', props.attrs, state.adopted !== undefined ? state.adopted : props.children);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
`
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The late-host half of the seam. `adoptHoleSeam` re-renders one hole through its live parent;
|
|
172
|
+
* `claimNestedIslandRoot` is what the island scan calls instead of mounting a nested host.
|
|
173
|
+
*/
|
|
174
|
+
function lateIslandClaimSource() {
|
|
175
|
+
return `
|
|
176
|
+
// The graft ($RC) swapped this hole's DOM wholesale, so re-adopting ALL of it is total: nothing in
|
|
177
|
+
// the hole is still preact-owned. The re-render lands inside the parent island's tree, which is what
|
|
178
|
+
// makes the new islands inherit its context.
|
|
179
|
+
// Detaching an element costs it every piece of state the browser keeps outside the DOM - a lazy
|
|
180
|
+
// image's deferred load observation, an iframe's document, playing media, focus - and re-inserting
|
|
181
|
+
// it does not bring any of that back. moveBefore is the state-preserving move (Chrome 133+); it
|
|
182
|
+
// needs both ends connected and refuses some moves outright, so a plain insert stays the fallback.
|
|
183
|
+
function movePnextNode(parent, node) {
|
|
184
|
+
if (parent.moveBefore && parent.isConnected && node.isConnected) {
|
|
185
|
+
try {
|
|
186
|
+
parent.moveBefore(node, null);
|
|
187
|
+
return;
|
|
188
|
+
} catch (error) {}
|
|
189
|
+
}
|
|
190
|
+
parent.append(node);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// A subtree rebuilt from adopted vnodes is made of freshly created elements, and a graft can bring
|
|
194
|
+
// in ones the browser had already deferred. Either way a lazy image can end up in the document with
|
|
195
|
+
// no live intersection observation - no request ever fires, and it stays blank forever. Re-running
|
|
196
|
+
// its source selection re-arms it WITHOUT giving up laziness; one already loading (currentSrc set)
|
|
197
|
+
// or already loaded is left alone, so this never forces a fetch or duplicates one.
|
|
198
|
+
// Drop the capture holder, moving its nodes into \`into\` first when the adoption cannot use them.
|
|
199
|
+
function releaseHoleCapture(hole, into) {
|
|
200
|
+
const captured = hole.__pnextCaptured;
|
|
201
|
+
if (!captured) return;
|
|
202
|
+
hole.__pnextCaptured = undefined;
|
|
203
|
+
if (into) for (const node of [...captured.childNodes]) movePnextNode(into, node);
|
|
204
|
+
captured.remove();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function rekickLazyLoadables(root) {
|
|
208
|
+
for (const img of root.querySelectorAll('img[loading=lazy]')) {
|
|
209
|
+
if (img.complete || img.currentSrc) continue;
|
|
210
|
+
for (const name of ['src', 'srcset']) {
|
|
211
|
+
const value = img.getAttribute(name);
|
|
212
|
+
if (value === null) continue;
|
|
213
|
+
img.removeAttribute(name);
|
|
214
|
+
img.setAttribute(name, value);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function adoptHoleSeam(hole) {
|
|
220
|
+
const pending = hole.__pnextAdopting;
|
|
221
|
+
if (pending) return pending;
|
|
222
|
+
const seam = hole.__pnextHole;
|
|
223
|
+
// No seam ever appeared: put the captured nodes back rather than losing them.
|
|
224
|
+
if (!seam) return releaseHoleCapture(hole, hole);
|
|
225
|
+
const adopting = (async () => {
|
|
226
|
+
const captured = hole.__pnextCaptured;
|
|
227
|
+
const children = await domChildren(captured ?? hole);
|
|
228
|
+
// Preact rebuilds the subtree from the adopted vnodes, so the server nodes they were read from
|
|
229
|
+
// would survive as unowned duplicates. Clearing and re-rendering within one task keeps the swap
|
|
230
|
+
// inside a single frame - preact's rerender is a microtask, so no paint sees the empty hole.
|
|
231
|
+
releaseHoleCapture(hole);
|
|
232
|
+
hole.replaceChildren();
|
|
233
|
+
seam.setState({ adopted: children ?? null }, () => rekickLazyLoadables(hole));
|
|
234
|
+
})();
|
|
235
|
+
hole.__pnextAdopting = adopting;
|
|
236
|
+
try {
|
|
237
|
+
await adopting;
|
|
238
|
+
} finally {
|
|
239
|
+
if (hole.__pnextAdopting === adopting) hole.__pnextAdopting = undefined;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// A nested host belongs to its parent island's DOM adoption pass, never to a mount of its own. Wait
|
|
244
|
+
// out an adoption already in flight (its walk may still reach this host), and claim only what the
|
|
245
|
+
// parent can no longer see - a host the graft materialized after the parent went live - through the
|
|
246
|
+
// hole seam that owns the grafted DOM. A host under a parent that has not mounted yet stays put.
|
|
247
|
+
async function claimNestedIslandRoot(root, parent) {
|
|
248
|
+
// The innermost hole whose seam is live owns this host. A nearer hole with no seam is raw grafted
|
|
249
|
+
// DOM inside an outer seam's region, so that outer one is the tree to re-adopt through.
|
|
250
|
+
let hole;
|
|
251
|
+
for (let node = root.parentElement; node && node !== parent; node = node.parentElement) {
|
|
252
|
+
if (node.localName !== 'pnext-hole') continue;
|
|
253
|
+
if (node.__pnextHole) { hole = node; break; }
|
|
254
|
+
hole ??= node;
|
|
255
|
+
}
|
|
256
|
+
// The parent is mid-adoption and has no seam here yet: its walk may already have snapshotted this
|
|
257
|
+
// hole's fallback, and its hydration then drops the DOM the graft just landed. Park the grafted
|
|
258
|
+
// nodes synchronously - ahead of that render - and adopt them from the holder once the tree is
|
|
259
|
+
// live. A walk that had NOT reached the hole yet simply adopts an empty one and gets the same
|
|
260
|
+
// content back through the seam. The holder is a hidden ELEMENT rather than a fragment: a
|
|
261
|
+
// disconnected parent is exactly what strips browser state, and moveBefore refuses one.
|
|
262
|
+
if (hole && !hole.__pnextHole && !hole.__pnextCaptured && parent.__pnextMounting) {
|
|
263
|
+
const holder = document.createElement('pnext-hole-capture');
|
|
264
|
+
holder.hidden = true;
|
|
265
|
+
document.body.append(holder);
|
|
266
|
+
hole.__pnextCaptured = holder;
|
|
267
|
+
for (const node of [...hole.childNodes]) movePnextNode(holder, node);
|
|
268
|
+
}
|
|
269
|
+
// A parent whose module failed still settles: the claim has to release its capture rather than
|
|
270
|
+
// leave the grafted nodes parked in a holder nobody will ever adopt from.
|
|
271
|
+
await parent.__pnextMounting?.catch(() => {});
|
|
272
|
+
if (!hole || (!hole.__pnextCaptured && !root.isConnected)) return;
|
|
273
|
+
return adoptHoleSeam(hole);
|
|
274
|
+
}
|
|
275
|
+
`
|
|
276
|
+
}
|
|
277
|
+
|
|
142
278
|
// Two mount passes can reach the same island root concurrently - mountRoute and the streamed-Suspense
|
|
143
279
|
// re-scan both walk the document, and their "already live" guard runs BEFORE mountIsland awaits the
|
|
144
280
|
// component module. Without a synchronous claim both passes mount: the first tree is orphaned over the
|
|
@@ -517,15 +653,21 @@ function keepsMountedRoot(root, keep) {
|
|
|
517
653
|
// The per-route half of the lifecycle, closing over the route's own mount
|
|
518
654
|
// state. mountRoute runs the steps the route actually has: an island scan, the
|
|
519
655
|
// client-shell hydration, the client page.
|
|
520
|
-
function routeLifecycleSource({ nextCompat, islands, page, shell }: ClientRuntimeFacts) {
|
|
656
|
+
function routeLifecycleSource({ nextCompat, islands, page, shell, dev }: ClientRuntimeFacts) {
|
|
521
657
|
const gate = nextCompat
|
|
522
658
|
? ' pnextGate(markRouteHydrated, pnextIslandMounts, pnextVisibleMount);\n'
|
|
523
659
|
: ''
|
|
660
|
+
const islandMounts = dev
|
|
661
|
+
? `pnextIslandScan.done.then(() => {
|
|
662
|
+
diagnoseSkippedIslands();
|
|
663
|
+
return pnextIslandScan.mounted;
|
|
664
|
+
})`
|
|
665
|
+
: 'pnextIslandScan.done.then(() => pnextIslandScan.mounted)'
|
|
524
666
|
const mountBody = [
|
|
525
667
|
islands
|
|
526
668
|
? ` if (islands.length > 0) {
|
|
527
669
|
pnextIslandScan = scanIslands();
|
|
528
|
-
pnextIslandMounts =
|
|
670
|
+
pnextIslandMounts = ${islandMounts};
|
|
529
671
|
}`
|
|
530
672
|
: '',
|
|
531
673
|
shell ? ' if (shell) pnextVisibleMount = hydrateClientShell();' : '',
|
|
@@ -655,6 +797,8 @@ ${overlay}installClientErrors({ errorComponent: ${
|
|
|
655
797
|
* ships only when some route in the app can actually use it.
|
|
656
798
|
*/
|
|
657
799
|
export interface ClientRuntimeFacts {
|
|
800
|
+
/** Development-only diagnostics; omitted entirely from production bundles. */
|
|
801
|
+
dev?: boolean
|
|
658
802
|
nextCompat?: boolean
|
|
659
803
|
/** Any route mounts islands (island scan, revival, static children). */
|
|
660
804
|
islands?: boolean
|
|
@@ -710,7 +854,7 @@ export function clientRuntimeSource(facts: ClientRuntimeFacts) {
|
|
|
710
854
|
// it revives island DOM exactly like an island mount does.
|
|
711
855
|
const revival = Boolean(facts.islands || shell)
|
|
712
856
|
return `
|
|
713
|
-
import { h, hydrate, render } from 'preact';
|
|
857
|
+
import { ${revival ? 'Component, ' : ''}h, hydrate, render } from 'preact';
|
|
714
858
|
${nextCompat ? "import { options as __pnextPreactOptions } from 'preact';" : ''}
|
|
715
859
|
${deferredIslands ? "import { useEffect, useState } from 'preact/hooks';" : ''}
|
|
716
860
|
${suspense ? "import { Suspense } from 'preact/compat';" : ''}
|
|
@@ -1137,6 +1281,10 @@ function domProps(element) {
|
|
|
1137
1281
|
return props;
|
|
1138
1282
|
}
|
|
1139
1283
|
|
|
1284
|
+
// A nested root belongs to its parent island's DOM adoption pass, never to a
|
|
1285
|
+
// mount of its own. This entry carries no streamed-hole seam (see the shared
|
|
1286
|
+
// runtime): its whole point is a preact-free first paint, and a host grafted in
|
|
1287
|
+
// after a visible island mounted would be dead weight in every bundle.
|
|
1140
1288
|
function isNestedIslandRoot(root) {
|
|
1141
1289
|
return root.parentElement?.closest('pnext-client') != null;
|
|
1142
1290
|
}
|
|
@@ -1343,9 +1491,39 @@ function clientBoundaryHelperSource(nextCompat?: boolean, suspense?: boolean) {
|
|
|
1343
1491
|
`
|
|
1344
1492
|
}
|
|
1345
1493
|
|
|
1346
|
-
function hydrateIslandsSource({
|
|
1494
|
+
function hydrateIslandsSource({
|
|
1495
|
+
nextCompat,
|
|
1496
|
+
visibleIslands,
|
|
1497
|
+
deferredIslands,
|
|
1498
|
+
dev,
|
|
1499
|
+
}: ClientRuntimeFacts) {
|
|
1500
|
+
const diagnostic = dev
|
|
1501
|
+
? `
|
|
1502
|
+
function diagnoseSkippedIslands() {
|
|
1503
|
+
for (const root of document.querySelectorAll('pnext-client[data-pnext-client]')) {
|
|
1504
|
+
if (root.__pnextLive) continue;
|
|
1505
|
+
const id = root.getAttribute('data-pnext-client');
|
|
1506
|
+
const island = id ? islandById.get(id) : undefined;
|
|
1507
|
+
const parent = nestedIslandParent(root);
|
|
1508
|
+
// A visible island is deliberately inert until intersection. A nested
|
|
1509
|
+
// island whose parent is still pending is likewise owned by that parent's
|
|
1510
|
+
// adoption pass, one parked in a capture holder is mid-adoption, and one the
|
|
1511
|
+
// claim pass adopted is no longer in the document. Everything else is a host
|
|
1512
|
+
// this route can never hydrate.
|
|
1513
|
+
if (island?.options?.load === 'visible') continue;
|
|
1514
|
+
if (root.closest('pnext-hole-capture')) continue;
|
|
1515
|
+
if (parent && !parent.__pnextLive) continue;
|
|
1516
|
+
const reason = island
|
|
1517
|
+
? 'was materialized after its parent island mounted, outside any streamed hole this route can re-adopt'
|
|
1518
|
+
: 'has no entry in the client island manifest';
|
|
1519
|
+
console.warn('pnext: client island ' + (id ?? '<missing id>') + ' ' + reason);
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
`
|
|
1523
|
+
: ''
|
|
1347
1524
|
return `
|
|
1348
1525
|
const islandById = new Map(islands.map(island => [island.id, island]));
|
|
1526
|
+
${lateIslandClaimSource()}${diagnostic}
|
|
1349
1527
|
|
|
1350
1528
|
// The mounted count is known as soon as the scan loop finishes, before any
|
|
1351
1529
|
// island module resolves — so the client shell can decide whether it owns the
|
|
@@ -1364,7 +1542,11 @@ for (const island of islands) {
|
|
|
1364
1542
|
}
|
|
1365
1543
|
for (const island of islands) {
|
|
1366
1544
|
for (const root of document.querySelectorAll(\`[data-pnext-client="\${island.id}"]\`)) {
|
|
1367
|
-
|
|
1545
|
+
const parent = nestedIslandParent(root);
|
|
1546
|
+
if (parent) {
|
|
1547
|
+
pending.push(claimNestedIslandRoot(root, parent));
|
|
1548
|
+
continue;
|
|
1549
|
+
}
|
|
1368
1550
|
// Already mounted (e.g. a re-scan after a streamed Suspense hole materialized new islands):
|
|
1369
1551
|
// mount() stamps __pnextLive, so skipping these leaves the shell's live islands untouched. A root
|
|
1370
1552
|
// preserved across a soft navigation is ALSO live, but carries the incoming placeholder (fresh SSR
|
|
@@ -1476,6 +1658,10 @@ async function domNode(node) {
|
|
|
1476
1658
|
|
|
1477
1659
|
const element = node;
|
|
1478
1660
|
if (element.localName === ${JSON.stringify(ISLAND_BOUNDARY_ERROR_ELEMENT)}) return pnextBoundaryErrorVNode(element);
|
|
1661
|
+
// The seam a late streamed graft re-adopts through (see holeSeamSource).
|
|
1662
|
+
if (element.localName === 'pnext-hole') {
|
|
1663
|
+
return h(PnextHole, { attrs: domProps(element) }, await domChildren(element));
|
|
1664
|
+
}
|
|
1479
1665
|
if (element.localName === 'pnext-client') {
|
|
1480
1666
|
const id = element.getAttribute('data-pnext-client');
|
|
1481
1667
|
const island = id ? islandById.get(id) : undefined;
|
|
@@ -1522,12 +1708,15 @@ function domProps(element) {
|
|
|
1522
1708
|
return props;
|
|
1523
1709
|
}
|
|
1524
1710
|
|
|
1525
|
-
|
|
1526
|
-
|
|
1711
|
+
// The island host that owns this root's DOM, if any. A nested root is never
|
|
1712
|
+
// mounted here: the parent adopts it, or claimNestedIslandRoot routes it through
|
|
1713
|
+
// the parent's hole seam.
|
|
1714
|
+
function nestedIslandParent(root) {
|
|
1715
|
+
return root.parentElement?.closest('pnext-client');
|
|
1527
1716
|
}
|
|
1528
|
-
${
|
|
1529
|
-
|
|
1530
|
-
|
|
1717
|
+
${holeSeamSource()}${
|
|
1718
|
+
visibleIslands
|
|
1719
|
+
? `
|
|
1531
1720
|
function visibleTarget(root) {
|
|
1532
1721
|
// The host's OWN box: hostMeasureSource delegates the patched one to the
|
|
1533
1722
|
// parent, and IntersectionObserver never fires for a display:contents target.
|
|
@@ -1536,8 +1725,8 @@ function visibleTarget(root) {
|
|
|
1536
1725
|
return root.parentElement ?? root;
|
|
1537
1726
|
}
|
|
1538
1727
|
`
|
|
1539
|
-
|
|
1540
|
-
}${mountOnceSource()}${
|
|
1728
|
+
: ''
|
|
1729
|
+
}${mountOnceSource()}${
|
|
1541
1730
|
deferredIslands
|
|
1542
1731
|
? `
|
|
1543
1732
|
function DeferredIsland({ load, props, children }) {
|
|
@@ -4810,9 +4810,11 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
|
|
|
4810
4810
|
// Same-entry-identity navigation (a search-param nav on the same pathname, a refresh):
|
|
4811
4811
|
// `key={bfcacheId}` subtrees do NOT remount, so typed values must survive. The commit can
|
|
4812
4812
|
// still rebuild the page DOM under them (a PPR route streams its page slot in AFTER the
|
|
4813
|
-
// swap), so re-apply the departure snapshot as the controls appear
|
|
4813
|
+
// swap), so re-apply the departure snapshot as the controls appear - but only to the DOM
|
|
4814
|
+
// this swap replaced, never to a control a live island's own render owns (see
|
|
4815
|
+
// islandOwnedControl).
|
|
4814
4816
|
else if (departingBfcacheId && departingBfcacheId === historyBfcacheId()) {
|
|
4815
|
-
restoreFormStateWhenMounted(departingBfcacheId, sequence)
|
|
4817
|
+
restoreFormStateWhenMounted(departingBfcacheId, sequence, true)
|
|
4816
4818
|
}
|
|
4817
4819
|
|
|
4818
4820
|
// A navigation that only changed parallel slots (same children path AND unchanged search
|
|
@@ -4907,13 +4909,25 @@ function restoreControl(
|
|
|
4907
4909
|
if (control.value !== saved.value) control.value = saved.value
|
|
4908
4910
|
}
|
|
4909
4911
|
|
|
4910
|
-
|
|
4912
|
+
/**
|
|
4913
|
+
* A control inside a mounted island is owned by that island's preact diff, which reproduces
|
|
4914
|
+
* React's reconciliation - including an app key (`key={searchParams.get('q')}`) that
|
|
4915
|
+
* deliberately remounts the subtree with an EMPTY control. Re-applying the departure snapshot
|
|
4916
|
+
* there fights the app; only DOM the raw swap replaced is pnext's own loss to compensate.
|
|
4917
|
+
*/
|
|
4918
|
+
function islandOwnedControl(control: Element): boolean {
|
|
4919
|
+
const root: LiveIslandRoot | null = control.closest('pnext-client[data-pnext-client]')
|
|
4920
|
+
return Boolean(root?.__pnextLive)
|
|
4921
|
+
}
|
|
4922
|
+
|
|
4923
|
+
function restoreFormState(bfcacheId: string | undefined, onlyEmpty = false, skipIslands = false) {
|
|
4911
4924
|
if (!bfcacheId) return
|
|
4912
4925
|
const state = entryFormStateCache.get(bfcacheId)
|
|
4913
4926
|
if (!state) return
|
|
4914
4927
|
const controls = formControls()
|
|
4915
4928
|
if (controls.length !== state.length) return
|
|
4916
4929
|
for (const [index, control] of controls.entries()) {
|
|
4930
|
+
if (skipIslands && islandOwnedControl(control)) continue
|
|
4917
4931
|
// `onlyEmpty`: a freshly mounted control is empty, so filling it restores
|
|
4918
4932
|
// what the remount lost — while anything already carrying a value (the app
|
|
4919
4933
|
// set it, or the user typed after the commit) is left alone.
|
|
@@ -4951,10 +4965,10 @@ function restoreSharedLayoutFormState(
|
|
|
4951
4965
|
* describes can appear after the swap. Keep trying while the DOM changes until the control
|
|
4952
4966
|
* set matches the snapshot.
|
|
4953
4967
|
*/
|
|
4954
|
-
function restoreFormStateWhenMounted(bfcacheId: string, sequence: number)
|
|
4968
|
+
function restoreFormStateWhenMounted(bfcacheId: string, sequence: number, skipIslands = false) {
|
|
4955
4969
|
const state = entryFormStateCache.get(bfcacheId)
|
|
4956
4970
|
if (!state) return
|
|
4957
|
-
restoreFormState(bfcacheId, true)
|
|
4971
|
+
restoreFormState(bfcacheId, true, skipIslands)
|
|
4958
4972
|
// The controls can be rebuilt more than once while the commit settles, so the restore stays
|
|
4959
4973
|
// armed for a short window instead of firing once. It only ever fills EMPTY controls, so a
|
|
4960
4974
|
// value the app or the user set after the commit is never clobbered.
|
|
@@ -4963,7 +4977,7 @@ function restoreFormStateWhenMounted(bfcacheId: string, sequence: number): void
|
|
|
4963
4977
|
observer.disconnect()
|
|
4964
4978
|
return
|
|
4965
4979
|
}
|
|
4966
|
-
restoreFormState(bfcacheId, true)
|
|
4980
|
+
restoreFormState(bfcacheId, true, skipIslands)
|
|
4967
4981
|
})
|
|
4968
4982
|
observer.observe(document.body, { childList: true, subtree: true })
|
|
4969
4983
|
window.setTimeout(() => observer.disconnect(), FORM_STATE_RESTORE_WINDOW_MS)
|
package/src/compat/metadata.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Metadata, MetadataOpenGraph, PageProps, RouteManifestEntry, Viewport } from '../types'
|
|
2
2
|
import { staticMetadataForPathFromFiles, type StaticMetadataFile } from '../routing/metadata-files'
|
|
3
3
|
import { coerceString } from '../render/metadata'
|
|
4
|
+
import { legacyRequestAPIs } from '../request/context'
|
|
4
5
|
import { readSourceText } from '../resolve/source-text'
|
|
5
6
|
import { SPAN_TYPE, withChildSpan } from './otel/tracer'
|
|
6
7
|
import { settledPromise } from '../render/renderer'
|
|
@@ -146,7 +147,9 @@ function resolvedParentMetadata(parent: Metadata): Metadata {
|
|
|
146
147
|
}
|
|
147
148
|
}
|
|
148
149
|
|
|
149
|
-
function resolvedParentOpenGraph(
|
|
150
|
+
function resolvedParentOpenGraph(
|
|
151
|
+
openGraph: MetadataOpenGraph | null | undefined,
|
|
152
|
+
): MetadataOpenGraph {
|
|
150
153
|
if (!openGraph) return {}
|
|
151
154
|
return {
|
|
152
155
|
...openGraph,
|
|
@@ -165,7 +168,7 @@ function resolvedParentOpenGraph(openGraph: MetadataOpenGraph | undefined): Meta
|
|
|
165
168
|
}
|
|
166
169
|
|
|
167
170
|
function normalizeKeywordList(value: Metadata['keywords']): (string | number)[] | undefined {
|
|
168
|
-
if (value === undefined) return undefined
|
|
171
|
+
if (value === undefined || value === null) return undefined
|
|
169
172
|
return Array.isArray(value) ? value : [value]
|
|
170
173
|
}
|
|
171
174
|
|
|
@@ -192,7 +195,8 @@ export async function warnNextMetadataBuildIssues(
|
|
|
192
195
|
if (route.kind !== 'page') continue
|
|
193
196
|
const routeFacts = await Promise.all(routeSourceFiles(appPath, route).map(factsFor))
|
|
194
197
|
const some = (pick: (fact: MetadataSourceFacts) => boolean) => routeFacts.some(pick)
|
|
195
|
-
|
|
198
|
+
// themeColor-in-metadata deprecation is Next 15+; legacy (Next-14 era) apps never saw it.
|
|
199
|
+
if (!legacyRequestAPIs() && some(fact => fact.themeColor)) {
|
|
196
200
|
warnOnce(
|
|
197
201
|
warned,
|
|
198
202
|
`themeColor:${route.route}`,
|
|
@@ -255,7 +259,7 @@ interface MetadataSourceFacts {
|
|
|
255
259
|
|
|
256
260
|
function metadataSourceFacts(source: string): MetadataSourceFacts {
|
|
257
261
|
return {
|
|
258
|
-
themeColor:
|
|
262
|
+
themeColor: themeColorInMetadataExport(source),
|
|
259
263
|
appleTouchFullscreen:
|
|
260
264
|
source.includes('apple-touch-fullscreen') && /['"]apple-touch-fullscreen['"]/.test(source),
|
|
261
265
|
appleTouchIconPrecomposed:
|
|
@@ -266,6 +270,35 @@ function metadataSourceFacts(source: string): MetadataSourceFacts {
|
|
|
266
270
|
}
|
|
267
271
|
}
|
|
268
272
|
|
|
273
|
+
// A themeColor inside a viewport export is where the notice says to put it, so
|
|
274
|
+
// only occurrences outside viewport/generateViewport blocks count.
|
|
275
|
+
function themeColorInMetadataExport(source: string): boolean {
|
|
276
|
+
if (!source.includes('themeColor') || !/\bthemeColor\s*:/.test(source)) return false
|
|
277
|
+
return /\bthemeColor\s*:/.test(stripViewportExports(source))
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function stripViewportExports(source: string): string {
|
|
281
|
+
const starts =
|
|
282
|
+
/export\s+(?:const|let|var)\s+viewport\b|export\s+(?:async\s+)?function\s+generateViewport\b/g
|
|
283
|
+
let out = ''
|
|
284
|
+
let last = 0
|
|
285
|
+
for (let match = starts.exec(source); match; match = starts.exec(source)) {
|
|
286
|
+
const open = source.indexOf('{', match.index)
|
|
287
|
+
if (open === -1) break
|
|
288
|
+
let depth = 0
|
|
289
|
+
let end = open
|
|
290
|
+
for (; end < source.length; end++) {
|
|
291
|
+
const char = source[end]
|
|
292
|
+
if (char === '{') depth++
|
|
293
|
+
else if (char === '}' && --depth === 0) break
|
|
294
|
+
}
|
|
295
|
+
out += source.slice(last, match.index)
|
|
296
|
+
last = end + 1
|
|
297
|
+
starts.lastIndex = last
|
|
298
|
+
}
|
|
299
|
+
return out + source.slice(last)
|
|
300
|
+
}
|
|
301
|
+
|
|
269
302
|
function routeUsesRelativeSocialImage(
|
|
270
303
|
route: RouteManifestEntry,
|
|
271
304
|
facts: MetadataSourceFacts[],
|
|
@@ -112,10 +112,16 @@ function findConfigPath(root: string): string | undefined {
|
|
|
112
112
|
return undefined
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
// `Bun` through globalThis: pnext ships TypeScript source, so app compilers without
|
|
116
|
+
// bun-types typecheck this file and a bare `Bun` identifier would fail them.
|
|
117
|
+
const { Bun: bun } = globalThis as unknown as {
|
|
118
|
+
Bun: { hash(input: string | ArrayBufferView): { toString(radix?: number): string } }
|
|
119
|
+
}
|
|
120
|
+
|
|
115
121
|
/** Stable per config content, so the emitted bundle keeps one name per boot. */
|
|
116
122
|
function configSourceKey(configPath: string): string {
|
|
117
123
|
try {
|
|
118
|
-
return
|
|
124
|
+
return bun.hash(readFileSync(configPath)).toString(36)
|
|
119
125
|
} catch {
|
|
120
126
|
return 'config'
|
|
121
127
|
}
|
|
@@ -8,6 +8,7 @@ import { recordRewrite } from '../protocol'
|
|
|
8
8
|
import { recordCanonicalUrl } from '../next/canonical-url'
|
|
9
9
|
import { getNextConfig } from '../next/config-loader'
|
|
10
10
|
import { activateTestProxy } from '../lifecycle/testmode'
|
|
11
|
+
import { legacyRequestAPIs } from '../../request/context'
|
|
11
12
|
|
|
12
13
|
export function registerProxyExtensions(_config: ResolvedConfig): void {
|
|
13
14
|
setProxyResponseProtocol({
|
|
@@ -72,7 +73,8 @@ async function validateNextProxyFiles(config: ResolvedConfig) {
|
|
|
72
73
|
`Both middleware file "${relativeProxyPath(config, middleware)}" and proxy file "${relativeProxyPath(config, proxy)}" are detected. Please use "${relativeProxyPath(config, proxy)}" only.`,
|
|
73
74
|
)
|
|
74
75
|
}
|
|
75
|
-
|
|
76
|
+
// Proxy is a Next 16 migration; legacy request APIs explicitly target the middleware era.
|
|
77
|
+
if (middleware && !proxy && !legacyRequestAPIs()) {
|
|
76
78
|
console.warn('The "middleware" file convention is deprecated. Please use "proxy" instead.')
|
|
77
79
|
}
|
|
78
80
|
await Promise.all(files.map(file => validateNextProxyFile(config, file)))
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
registerBuildSteps,
|
|
10
10
|
type BuildStepContext,
|
|
11
11
|
} from '../../extensions'
|
|
12
|
+
import { legacyRequestAPIs } from '../../request/context'
|
|
12
13
|
import type { RouteManifestEntry } from '../../types'
|
|
13
14
|
import { nextCompatEnabled } from '../aliases'
|
|
14
15
|
import { writeTsconfigDefaults } from '../tsconfig-defaults'
|
|
@@ -21,7 +22,8 @@ export function registerTypedRoutesExtensions(_config: ResolvedConfig): void {
|
|
|
21
22
|
|
|
22
23
|
async function runTypedRoutesBuildStep(ctx: BuildStepContext): Promise<void> {
|
|
23
24
|
if (!nextCompatEnabled(ctx.config)) return
|
|
24
|
-
|
|
25
|
+
// Legacy (Next-14 era) apps keep their tsconfig untouched; current apps get Next's defaults.
|
|
26
|
+
if (!legacyRequestAPIs()) await writeTsconfigDefaults(ctx.config)
|
|
25
27
|
await generateTypedRoutes(ctx.config, ctx.routes as RouteManifestEntry[])
|
|
26
28
|
// The typecheck reads only files that already exist on disk, so it runs on a worker thread
|
|
27
29
|
// alongside client bundling and prerendering rather than ahead of them; the CLI awaits it before
|
|
@@ -372,6 +372,10 @@ async function writeTypecheckProject(
|
|
|
372
372
|
},
|
|
373
373
|
include: [
|
|
374
374
|
path.join(dir, 'next-shims.d.ts'),
|
|
375
|
+
// Generated per-route assignability checks (typegen writes them next to this
|
|
376
|
+
// project, not under the app-included `types/` dir, so only pnext's own compiler
|
|
377
|
+
// reads them). Included by hand to keep that coverage.
|
|
378
|
+
path.join(dir, 'checks', '**/*.ts'),
|
|
375
379
|
...(nodeTypes ? [path.join(nodeTypes, 'index.d.ts')] : []),
|
|
376
380
|
path.join(config.root, 'next-env.d.ts'),
|
|
377
381
|
// The build emits `.next/types/validator.ts` (page/layout/route-handler export validation) just
|
|
@@ -661,7 +665,7 @@ declare module 'react/jsx-runtime' {
|
|
|
661
665
|
`
|
|
662
666
|
const requestType = (type: string) =>
|
|
663
667
|
legacyRequestTypes ? `Promise<${type}> & ${type}` : `Promise<${type}>`
|
|
664
|
-
const requestShims = `declare module 'next/headers'{interface CookieSetOptions{name?:string;value?:string;path?:string;maxAge?:number;expires?:Date|number;httpOnly?:boolean;secure?:boolean;sameSite?:'strict'|'lax'|'none'|boolean;domain?:string}interface CookieStore{get(name:string):{name:string;value:string}|undefined;get(options:{name:string;value?:string}):{name:string;value:string}|undefined;getAll(name?:string):{name:string;value:string}[];getAll(options:{name:string;value?:string}):{name:string;value:string}[];has(name:string):boolean;readonly size:number;set(name:string,value:string,options?:CookieSetOptions):CookieStore;
|
|
668
|
+
const requestShims = `declare module 'next/headers'{interface CookieSetOptions{name?:string;value?:string;path?:string;maxAge?:number;expires?:Date|number;httpOnly?:boolean;secure?:boolean;sameSite?:'strict'|'lax'|'none'|boolean;domain?:string}interface CookieStore{get(name:string):{name:string;value:string}|undefined;get(options:{name:string;value?:string}):{name:string;value:string}|undefined;getAll(name?:string):{name:string;value:string}[];getAll(options:{name:string;value?:string}):{name:string;value:string}[];has(name:string):boolean;readonly size:number;set(options:CookieSetOptions):CookieStore;set(name:string,value:string,options?:CookieSetOptions):CookieStore;delete(name:string):boolean;clear():CookieStore;[Symbol.iterator]():IterableIterator<readonly[string,{name?:string;value:string}]>}export function cookies():${requestType('CookieStore')};export function headers():${requestType('Headers')};export function draftMode():${requestType(`{isEnabled:boolean;enable():void;disable():void}`)}}
|
|
665
669
|
`
|
|
666
670
|
const fontShims = hasRealNext
|
|
667
671
|
? ''
|
|
@@ -15,6 +15,7 @@ import path from 'node:path'
|
|
|
15
15
|
import type { ResolvedConfig } from '../../config'
|
|
16
16
|
import { extraPageExtensions } from '../../extensions'
|
|
17
17
|
import type { RouteManifestEntry } from '../../types'
|
|
18
|
+
import { legacyRequestAPIs } from '../../request/context'
|
|
18
19
|
import { getNextConfig } from '../next/config-loader'
|
|
19
20
|
import { stripComments } from '../../utils/code'
|
|
20
21
|
import {
|
|
@@ -271,6 +272,7 @@ function warnBuildDiagnostics(config: ResolvedConfig, routes: RouteManifestEntry
|
|
|
271
272
|
console.warn('Disabled SWC as replacement for Babel because of custom Babel configuration')
|
|
272
273
|
}
|
|
273
274
|
if (
|
|
275
|
+
!legacyRequestAPIs() &&
|
|
274
276
|
typeof nextConfig.outputFileTracingRoot !== 'string' &&
|
|
275
277
|
!(
|
|
276
278
|
nextConfig.turbopack &&
|
package/src/config.ts
CHANGED
|
@@ -21,6 +21,12 @@ export type ResolvedConfig = Required<Pick<PNextConfig, 'outDir' | 'basePath'>>
|
|
|
21
21
|
outRootPath: string
|
|
22
22
|
/** Generated types — always under the out root, because tsconfig points at it. */
|
|
23
23
|
typesPath: string
|
|
24
|
+
/**
|
|
25
|
+
* Generated per-route assignability checks. Deliberately OUTSIDE typesPath: an app's
|
|
26
|
+
* tsconfig includes `<outRoot>/types/**` and a foreign compiler must never see pnext's
|
|
27
|
+
* own internals. pnext's private typecheck project includes this directory instead.
|
|
28
|
+
*/
|
|
29
|
+
checksPath: string
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
/**
|
|
@@ -146,6 +152,7 @@ export async function loadConfig(
|
|
|
146
152
|
outPath: options.dev ? path.join(outRootPath, devOutSegment) : outRootPath,
|
|
147
153
|
outRootPath,
|
|
148
154
|
typesPath: path.join(outRootPath, 'types'),
|
|
155
|
+
checksPath: path.join(outRootPath, 'typecheck', 'checks'),
|
|
149
156
|
}
|
|
150
157
|
}
|
|
151
158
|
|
package/src/dev/server.ts
CHANGED
|
@@ -100,6 +100,7 @@ import {
|
|
|
100
100
|
} from '../runtime/modules'
|
|
101
101
|
import { ssrClientReference } from '../client/reference'
|
|
102
102
|
import { cacheRoot, setDevWatcherFreshness } from '../runtime/module-cache'
|
|
103
|
+
import { abortUpstreamFetchOnDisconnect } from '../runtime/fetch-host'
|
|
103
104
|
import { markBoot } from '../cli/boot/trace'
|
|
104
105
|
import { formatDuration } from '../utils/verbose'
|
|
105
106
|
|
|
@@ -454,6 +455,7 @@ export async function startDevServer(options: DevServerOptions) {
|
|
|
454
455
|
syncWatchRoots()
|
|
455
456
|
}, 0)
|
|
456
457
|
if (profile) flushDevProfileLines()
|
|
458
|
+
abortUpstreamFetchOnDisconnect(logged.body, request.signal)
|
|
457
459
|
return flushWorkUnitOnClose(logged, unit, request.signal)
|
|
458
460
|
}
|
|
459
461
|
|
package/src/extensions.ts
CHANGED
|
@@ -15,6 +15,12 @@ import type { BuildManifest, ClientEntryReason, RouteManifestEntry, RouteParamVa
|
|
|
15
15
|
import type { VerboseLogger } from './utils/verbose'
|
|
16
16
|
import { escapeRegex } from './utils/code'
|
|
17
17
|
|
|
18
|
+
// `Bun` through globalThis: pnext ships TypeScript source, so app compilers without
|
|
19
|
+
// bun-types typecheck this file and a bare `Bun` identifier would fail them.
|
|
20
|
+
const { Bun: bun } = globalThis as unknown as {
|
|
21
|
+
Bun: { hash(input: string | ArrayBufferView): { toString(radix?: number): string } }
|
|
22
|
+
}
|
|
23
|
+
|
|
18
24
|
/** Per-build wiring for the server plugin chain. */
|
|
19
25
|
export interface ServerEsbuildPluginOptions {
|
|
20
26
|
/**
|
|
@@ -1416,7 +1422,7 @@ export function classifyRouteDependencies(
|
|
|
1416
1422
|
|
|
1417
1423
|
/** Every reason a registered detector requires a client entry for this source. */
|
|
1418
1424
|
export function sourceClientEntryReasons(source: string): ClientEntryReason[] {
|
|
1419
|
-
const key = `${source.length}\0${
|
|
1425
|
+
const key = `${source.length}\0${bun.hash(source).toString(36)}`
|
|
1420
1426
|
const cached = clientEntryReasonCache.get(key)
|
|
1421
1427
|
if (cached) return cached
|
|
1422
1428
|
const reasons: ClientEntryReason[] = []
|