@pylonsync/react 0.4.14 → 0.4.16

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,37 @@
1
+ import React, { type ComponentType } from "react";
2
+ export interface DynamicOptions {
3
+ /** Rendered while the chunk loads (and, when `ssr` is false, on the server). */
4
+ loading?: ComponentType<any> | null;
5
+ /**
6
+ * Render on the server as well as the client. Default **false**.
7
+ *
8
+ * This is the opposite of Next's default, and deliberately so. Under Pylon a
9
+ * page hydrates ONCE, after the whole document has streamed, so the safe
10
+ * contract is that the server HTML and the first client render are
11
+ * identical — which `ssr: false` guarantees by rendering the fallback in
12
+ * both. It is also the only mode that removes bytes from the first load: an
13
+ * `ssr: true` component has to be in the client bundle before hydration, so
14
+ * it saves nothing, and it's there for organizing code, not for weight.
15
+ */
16
+ ssr?: boolean;
17
+ }
18
+ type Loader<P> = () => Promise<{
19
+ default: ComponentType<P>;
20
+ } | ComponentType<P>>;
21
+ /**
22
+ * Defer a component into its own chunk.
23
+ *
24
+ * The returned component renders `loading` until the chunk arrives, then the
25
+ * real one. Call it at MODULE level, not inside a render — a `dynamic()` call
26
+ * per render creates a new component type each time and remounts the subtree.
27
+ *
28
+ * `ref` reaches the loaded component: React 19 treats it as an ordinary prop,
29
+ * so the spread carries it to a `forwardRef` target's imperative handle. That
30
+ * matters for the exact components people defer — an editor whose save path
31
+ * calls `ref.current.export()` would fail silently otherwise — so it's covered
32
+ * by a test rather than left to React's prop semantics staying put.
33
+ */
34
+ export declare function dynamic<P extends object>(loader: Loader<P>, options?: DynamicOptions): ComponentType<P & {
35
+ ref?: React.Ref<any>;
36
+ }>;
37
+ export {};
package/dist/index.d.ts CHANGED
@@ -2,6 +2,8 @@ export { defineRoute } from "@pylonsync/sdk";
2
2
  export type { RouteMode, AppManifest } from "@pylonsync/sdk";
3
3
  export { Link } from "./Link";
4
4
  export type { LinkProps } from "./Link";
5
+ export { dynamic } from "./dynamic";
6
+ export type { DynamicOptions } from "./dynamic";
5
7
  export { Image } from "./Image";
6
8
  export type { ImageProps } from "./Image";
7
9
  export { Form } from "./Form";
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.4.14",
6
+ "version": "0.4.16",
7
7
  "type": "module",
8
8
  "main": "./src/index.ts",
9
9
  "types": "./dist/index.d.ts",
@@ -14,8 +14,8 @@
14
14
  "prepack": "bun run build"
15
15
  },
16
16
  "dependencies": {
17
- "@pylonsync/sdk": "0.4.14",
18
- "@pylonsync/sync": "0.4.14"
17
+ "@pylonsync/sdk": "0.4.16",
18
+ "@pylonsync/sync": "0.4.16"
19
19
  },
20
20
  "peerDependencies": {
21
21
  "react": ">=19.0.0"
@@ -0,0 +1,131 @@
1
+ // Contract tests for dynamic() — component-level code splitting.
2
+ //
3
+ // The property that matters is that ssr:false cannot produce a hydration
4
+ // mismatch. Pylon hydrates ONCE, after the whole document has streamed, so
5
+ // the server HTML and the first client render must be identical. Rendering
6
+ // the fallback in BOTH is what guarantees that; a component that appeared on
7
+ // the first client pass would diverge from the server's markup.
8
+
9
+ import { afterEach, describe, expect, test } from "bun:test";
10
+ import { cleanup, render, screen, waitFor } from "@testing-library/react";
11
+ // react-dom/server ships no types in this workspace's resolution; the test
12
+ // only needs one function from it.
13
+ const { renderToStaticMarkup } = require("react-dom/server") as {
14
+ renderToStaticMarkup: (el: React.ReactElement) => string;
15
+ };
16
+ import React from "react";
17
+ import { dynamic } from "./dynamic";
18
+
19
+ // @testing-library auto-registers cleanup when it finds a global afterEach;
20
+ // bun:test doesn't provide one, so mounted trees would accumulate across tests
21
+ // in this file and queries would match components a later test never rendered.
22
+ afterEach(cleanup);
23
+
24
+ const Heavy = () => <div data-testid="heavy">heavy</div>;
25
+ const Skeleton = () => <div data-testid="skeleton">loading…</div>;
26
+
27
+ describe("dynamic() with ssr:false", () => {
28
+ test("server-renders the fallback, never the component", () => {
29
+ const D = dynamic(async () => ({ default: Heavy }), { loading: Skeleton });
30
+ const html = renderToStaticMarkup(<D />);
31
+ expect(html).toContain("loading…");
32
+ expect(html).not.toContain("heavy");
33
+ });
34
+
35
+ test("the FIRST client render matches the server's, then swaps", async () => {
36
+ // This is the anti-mismatch contract: identical first paint, real
37
+ // component only after an effect (which never runs during SSR).
38
+ const D = dynamic(async () => ({ default: Heavy }), { loading: Skeleton });
39
+ const html = renderToStaticMarkup(<D />);
40
+ const { container } = render(<D />);
41
+ expect(container.innerHTML).toBe(html);
42
+ await waitFor(() => expect(screen.getByTestId("heavy")).toBeDefined());
43
+ });
44
+
45
+ test("renders nothing rather than crashing when no fallback is given", () => {
46
+ const D = dynamic(async () => ({ default: Heavy }));
47
+ expect(renderToStaticMarkup(<D />)).toBe("");
48
+ });
49
+
50
+ test("passes props through to the loaded component", async () => {
51
+ const Greet = ({ name }: { name: string }) => <span data-testid="g">hi {name}</span>;
52
+ const D = dynamic(async () => ({ default: Greet }), { loading: Skeleton });
53
+ render(<D name="eric" />);
54
+ await waitFor(() => expect(screen.getByTestId("g").textContent).toBe("hi eric"));
55
+ });
56
+
57
+ test("accepts a module namespace or a bare component", async () => {
58
+ const D = dynamic(async () => Heavy as any, { loading: Skeleton });
59
+ render(<D />);
60
+ await waitFor(() => expect(screen.getByTestId("heavy")).toBeDefined());
61
+ });
62
+
63
+ test("loads the chunk once across multiple instances", async () => {
64
+ let calls = 0;
65
+ const D = dynamic(
66
+ async () => {
67
+ calls++;
68
+ return { default: Heavy };
69
+ },
70
+ { loading: Skeleton },
71
+ );
72
+ render(
73
+ <>
74
+ <D />
75
+ <D />
76
+ <D />
77
+ </>,
78
+ );
79
+ await waitFor(() => expect(screen.getAllByTestId("heavy")).toHaveLength(3));
80
+ expect(calls).toBe(1);
81
+ });
82
+
83
+ test("a failed chunk leaves the fallback up instead of blanking", async () => {
84
+ const orig = console.error;
85
+ console.error = () => {};
86
+ try {
87
+ const D = dynamic(async () => {
88
+ throw new Error("network");
89
+ }, { loading: Skeleton });
90
+ render(<D />);
91
+ await new Promise((r) => setTimeout(r, 20));
92
+ expect(screen.getByTestId("skeleton")).toBeDefined();
93
+ } finally {
94
+ console.error = orig;
95
+ }
96
+ });
97
+ });
98
+
99
+ describe("dynamic() with ssr:true", () => {
100
+ test("server-renders the real component", async () => {
101
+ const D = dynamic(async () => ({ default: Heavy }), {
102
+ ssr: true,
103
+ loading: Skeleton,
104
+ });
105
+ // React resolves lazy during a streaming server render; renderToStaticMarkup
106
+ // is sync, so drive it through the client renderer instead.
107
+ render(<D />);
108
+ await waitFor(() => expect(screen.getByTestId("heavy")).toBeDefined());
109
+ });
110
+ });
111
+
112
+ describe("dynamic() and refs", () => {
113
+ test("a ref reaches the loaded component's imperative handle", async () => {
114
+ // React 19 passes `ref` as an ordinary prop, so the spread carries it —
115
+ // but that's incidental enough to be worth pinning: a deferred editor
116
+ // whose save path calls ref.current.export() breaks silently otherwise.
117
+ const Editor = React.forwardRef<{ save: () => string }, { doc: string }>(
118
+ (props, ref) => {
119
+ React.useImperativeHandle(ref, () => ({ save: () => `saved:${props.doc}` }));
120
+ return <div data-testid="ed">{props.doc}</div>;
121
+ },
122
+ );
123
+ const D = dynamic<{ doc: string }>(async () => ({ default: Editor as any }), {
124
+ loading: Skeleton,
125
+ });
126
+ const ref = React.createRef<{ save: () => string }>();
127
+ render(<D doc="hello" ref={ref} />);
128
+ await waitFor(() => expect(screen.getByTestId("ed")).toBeDefined());
129
+ expect(ref.current?.save()).toBe("saved:hello");
130
+ });
131
+ });
@@ -0,0 +1,127 @@
1
+ // Component-level code splitting — `next/dynamic` parity for Pylon.
2
+ //
3
+ // Route-level splitting gives every page its own entry chunk, which solves
4
+ // having many routes. It does nothing for ONE route with a heavy dependency: a
5
+ // page that statically imports a rich-text editor puts the whole editor in its
6
+ // entry, and since <Link> warms sibling routes on sight, that weight lands on
7
+ // the first load of every page in the section — for a component most visitors
8
+ // never open.
9
+ //
10
+ // `dynamic()` moves it behind a real `import()`, which the bundler emits as
11
+ // its own chunk and (deliberately) leaves out of the route's modulepreload
12
+ // set, so the bytes ship only when something actually renders it.
13
+ //
14
+ // const Editor = dynamic(() => import("./Editor"), {
15
+ // loading: () => <EditorSkeleton />,
16
+ // });
17
+ //
18
+ // NOTE the name collision, which is easy to misread: the route-segment export
19
+ // `export const dynamic = "force-static" | "force-dynamic"` is a CACHE
20
+ // directive and unrelated to this. Same word, different thing, both from Next.
21
+
22
+ import React, {
23
+ Suspense,
24
+ lazy,
25
+ useEffect,
26
+ useState,
27
+ type ComponentType,
28
+ } from "react";
29
+
30
+ export interface DynamicOptions {
31
+ /** Rendered while the chunk loads (and, when `ssr` is false, on the server). */
32
+ loading?: ComponentType<any> | null;
33
+ /**
34
+ * Render on the server as well as the client. Default **false**.
35
+ *
36
+ * This is the opposite of Next's default, and deliberately so. Under Pylon a
37
+ * page hydrates ONCE, after the whole document has streamed, so the safe
38
+ * contract is that the server HTML and the first client render are
39
+ * identical — which `ssr: false` guarantees by rendering the fallback in
40
+ * both. It is also the only mode that removes bytes from the first load: an
41
+ * `ssr: true` component has to be in the client bundle before hydration, so
42
+ * it saves nothing, and it's there for organizing code, not for weight.
43
+ */
44
+ ssr?: boolean;
45
+ }
46
+
47
+ type Loader<P> = () => Promise<{ default: ComponentType<P> } | ComponentType<P>>;
48
+
49
+ /** A module namespace or the component itself — accept both, like Next. */
50
+ function resolveComponent<P>(
51
+ mod: { default: ComponentType<P> } | ComponentType<P>,
52
+ ): ComponentType<P> {
53
+ return (mod as { default: ComponentType<P> })?.default ?? (mod as ComponentType<P>);
54
+ }
55
+
56
+ /**
57
+ * Defer a component into its own chunk.
58
+ *
59
+ * The returned component renders `loading` until the chunk arrives, then the
60
+ * real one. Call it at MODULE level, not inside a render — a `dynamic()` call
61
+ * per render creates a new component type each time and remounts the subtree.
62
+ *
63
+ * `ref` reaches the loaded component: React 19 treats it as an ordinary prop,
64
+ * so the spread carries it to a `forwardRef` target's imperative handle. That
65
+ * matters for the exact components people defer — an editor whose save path
66
+ * calls `ref.current.export()` would fail silently otherwise — so it's covered
67
+ * by a test rather than left to React's prop semantics staying put.
68
+ */
69
+ export function dynamic<P extends object>(
70
+ loader: Loader<P>,
71
+ options: DynamicOptions = {},
72
+ ): ComponentType<P & { ref?: React.Ref<any> }> {
73
+ const { loading: Loading = null, ssr = false } = options;
74
+
75
+ if (ssr) {
76
+ // Server-rendered: React awaits the lazy component during the SSR render,
77
+ // and Suspense covers the client while the chunk loads. No first-load
78
+ // saving — the component is in the tree the server rendered — so this is
79
+ // for splitting code, not weight.
80
+ const Lazy = lazy(async () => {
81
+ const mod = await loader();
82
+ return { default: resolveComponent<P>(mod) as ComponentType<any> };
83
+ });
84
+ const WithSuspense = (props: P) => (
85
+ <Suspense fallback={Loading ? <Loading /> : null}>
86
+ <Lazy {...(props as any)} />
87
+ </Suspense>
88
+ );
89
+ WithSuspense.displayName = "DynamicSSR";
90
+ return WithSuspense as ComponentType<P & { ref?: React.Ref<any> }>;
91
+ }
92
+
93
+ // Client-only. The fallback renders on the server AND on the first client
94
+ // pass, so the two agree byte for byte and hydration cannot mismatch; the
95
+ // real component swaps in from an effect, which never runs during SSR.
96
+ //
97
+ // The promise is cached on first use so remounts don't refetch, and so two
98
+ // instances of the same dynamic component share one request.
99
+ let cached: Promise<ComponentType<P>> | null = null;
100
+ const load = () => (cached ??= Promise.resolve(loader()).then(resolveComponent<P>));
101
+
102
+ const ClientOnly = (props: P) => {
103
+ const [Loaded, setLoaded] = useState<ComponentType<P> | null>(null);
104
+ useEffect(() => {
105
+ let alive = true;
106
+ load().then(
107
+ (C) => {
108
+ // The functional form — setState treats a bare function as an
109
+ // updater, and a component IS a function.
110
+ if (alive) setLoaded(() => C);
111
+ },
112
+ (err) => {
113
+ // A chunk that fails to load (deploy mid-session, offline) leaves
114
+ // the fallback up rather than blanking the subtree.
115
+ console.error("[pylon] dynamic() failed to load a component:", err);
116
+ },
117
+ );
118
+ return () => {
119
+ alive = false;
120
+ };
121
+ }, []);
122
+ if (!Loaded) return Loading ? <Loading /> : null;
123
+ return <Loaded {...(props as any)} />;
124
+ };
125
+ ClientOnly.displayName = "Dynamic";
126
+ return ClientOnly as ComponentType<P & { ref?: React.Ref<any> }>;
127
+ }
package/src/index.ts CHANGED
@@ -5,6 +5,8 @@ export type { RouteMode, AppManifest } from "@pylonsync/sdk";
5
5
  // progressively (work without JS) and enhance on the client.
6
6
  export { Link } from "./Link";
7
7
  export type { LinkProps } from "./Link";
8
+ export { dynamic } from "./dynamic";
9
+ export type { DynamicOptions } from "./dynamic";
8
10
  export { Image } from "./Image";
9
11
  export type { ImageProps } from "./Image";
10
12
  export { Form } from "./Form";