react-snapshot-bridge 1.2.0 → 1.2.1

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 CHANGED
@@ -46,13 +46,21 @@ Peer dependency: `react >= 16.3` (when `getSnapshotBeforeUpdate` was introduced)
46
46
  import { useRef, useState } from "react";
47
47
  import { SnapshotBeforeUpdate } from "react-snapshot-bridge";
48
48
 
49
+ type Message = { id: number; text: string };
50
+
49
51
  function ChatList() {
50
52
  const scrollerRef = useRef<HTMLDivElement>(null);
51
- const [messages, setMessages] = useState<string[]>(["hi"]);
53
+ const nextId = useRef(1);
54
+ const [messages, setMessages] = useState<Message[]>([{ id: 0, text: "hi" }]);
52
55
 
53
56
  return (
54
57
  <>
55
- <button onClick={() => setMessages((m) => ["new message", ...m])}>
58
+ <button
59
+ onClick={() => {
60
+ const id = nextId.current++;
61
+ setMessages((m) => [{ id, text: "new message" }, ...m]);
62
+ }}
63
+ >
56
64
  Prepend
57
65
  </button>
58
66
 
@@ -66,9 +74,12 @@ function ChatList() {
66
74
  el.scrollTop = el.scrollHeight - prevDistanceFromBottom;
67
75
  }}
68
76
  >
69
- <div ref={scrollerRef} style={{ height: 200, overflow: "auto" }}>
70
- {messages.map((msg, i) => (
71
- <div key={i}>{msg}</div>
77
+ <div
78
+ ref={scrollerRef}
79
+ style={{ height: 200, overflow: "auto", overflowAnchor: "none" }}
80
+ >
81
+ {messages.map((msg) => (
82
+ <div key={msg.id}>{msg.text}</div>
72
83
  ))}
73
84
  </div>
74
85
  </SnapshotBeforeUpdate>
@@ -79,26 +90,32 @@ function ChatList() {
79
90
 
80
91
  Without the wrapper, every prepend visually shifts the user's content down. With it, the visible content stays anchored.
81
92
 
93
+ Give each row a stable `id`. An index key makes React reuse the existing nodes from the top, so a prepend is reconciled like an append and this anchor no longer matches what the reader is looking at.
94
+
95
+ `overflow-anchor: none` turns off the browser's own scroll anchoring. Without that, Chrome and Safari often keep the same row in view by themselves, and the jump this wrapper corrects stays hidden.
96
+
82
97
  ## Capturing multiple values
83
98
 
84
99
  `capture` returns a single value, but that value can be **any shape** — including an object. This is the recommended way to forward multiple pieces of state in one commit, and it keeps the API surface minimal and type-safe.
85
100
 
86
101
  The example below combines two ideas at once:
87
102
 
88
- 1. Capture two DOM-derived values (`distanceFromBottom`, `wasAtBottom`) into one snapshot.
103
+ 1. Capture the scroll anchor (`anchorFromBottom`, the same `scrollHeight - scrollTop` value as the quick start) and whether the reader was already at the bottom.
89
104
  2. Track the **previous** message count with a ref (synced at the end of `apply`), read the **current** `messages` in `apply` via closure, and read DOM in `capture` before the commit mutates it.
90
105
 
91
106
  ```tsx
92
- import { useRef, useState } from "react";
107
+ import { useRef } from "react";
93
108
  import { SnapshotBeforeUpdate } from "react-snapshot-bridge";
94
109
 
110
+ type Message = { id: number; text: string };
111
+
95
112
  type Snap = {
96
- distanceFromBottom: number;
113
+ anchorFromBottom: number;
97
114
  wasAtBottom: boolean;
98
115
  previousMessageCount: number;
99
116
  };
100
117
 
101
- function ChatList({ messages }: { messages: string[] }) {
118
+ function ChatList({ messages }: { messages: Message[] }) {
102
119
  const scrollerRef = useRef<HTMLDivElement>(null);
103
120
  const prevMessageCountRef = useRef(messages.length);
104
121
 
@@ -106,32 +123,35 @@ function ChatList({ messages }: { messages: string[] }) {
106
123
  <SnapshotBeforeUpdate<Snap>
107
124
  capture={() => {
108
125
  const el = scrollerRef.current!;
109
- const distanceFromBottom =
110
- el.scrollHeight - el.scrollTop - el.clientHeight;
126
+ const anchorFromBottom = el.scrollHeight - el.scrollTop;
127
+ const gapBelowViewport = anchorFromBottom - el.clientHeight;
111
128
  return {
112
- distanceFromBottom,
113
- wasAtBottom: distanceFromBottom < 16,
129
+ anchorFromBottom,
130
+ wasAtBottom: gapBelowViewport < 16,
114
131
  // Still the count from the end of the last `apply` (or initial render).
115
132
  previousMessageCount: prevMessageCountRef.current,
116
133
  };
117
134
  }}
118
- apply={({ distanceFromBottom, wasAtBottom, previousMessageCount }) => {
135
+ apply={({ anchorFromBottom, wasAtBottom, previousMessageCount }) => {
119
136
  const el = scrollerRef.current!;
120
137
  const grewAtTop = messages.length > previousMessageCount;
121
138
 
122
139
  if (wasAtBottom) {
123
140
  el.scrollTop = el.scrollHeight; // stick to bottom for new arrivals
124
141
  } else if (grewAtTop) {
125
- el.scrollTop = el.scrollHeight - distanceFromBottom; // anchor existing content
142
+ el.scrollTop = el.scrollHeight - anchorFromBottom; // anchor existing content
126
143
  }
127
144
 
128
145
  // Next `capture` reads this as the "previous" count for that commit.
129
146
  prevMessageCountRef.current = messages.length;
130
147
  }}
131
148
  >
132
- <div ref={scrollerRef} style={{ height: 200, overflow: "auto" }}>
133
- {messages.map((msg, i) => (
134
- <div key={i}>{msg}</div>
149
+ <div
150
+ ref={scrollerRef}
151
+ style={{ height: 200, overflow: "auto", overflowAnchor: "none" }}
152
+ >
153
+ {messages.map((msg) => (
154
+ <div key={msg.id}>{msg.text}</div>
135
155
  ))}
136
156
  </div>
137
157
  </SnapshotBeforeUpdate>
@@ -143,11 +163,11 @@ function ChatList({ messages }: { messages: string[] }) {
143
163
 
144
164
  ## API
145
165
 
146
- ### `<SnapshotBeforeUpdate<T> capture apply enabled?>{children?}</SnapshotBeforeUpdate>`
166
+ ### `<SnapshotBeforeUpdate<T> capture apply enabled? deps?>{children?}</SnapshotBeforeUpdate>`
147
167
 
148
168
  | Prop | Type | Required | Description |
149
169
  | ---------- | ----------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------- |
150
- | `capture` | `() => T` | yes | Called synchronously **before** DOM mutation. Return value is forwarded to `apply`. |
170
+ | `capture` | `() => T` | yes | Called synchronously **before** DOM mutation. Return value, including `undefined`, is forwarded to `apply`. |
151
171
  | `apply` | `(snapshot: T) => void` | yes | Called synchronously **after** DOM mutation, before paint. Receives the value from `capture`. |
152
172
  | `enabled` | `boolean` | no | Defaults to `true`. When `false`, both `capture` and `apply` are skipped for that commit. Missed updates are not replayed. |
153
173
  | `deps` | `readonly unknown[]` | no | Optional dependency array, compared shallowly (`Object.is`) against the previous commit. When provided, both callbacks run only on commits where at least one entry changed. When omitted, they run on every update. |
@@ -228,6 +248,7 @@ The captured value is forwarded to `componentDidUpdate`, which calls `apply`. Fr
228
248
  - **`deps={[]}` differs from `useEffect`.** Because an empty array never changes and this lifecycle never runs on mount, `deps={[]}` means "never run after mount" — not "run once on mount" as it would with `useEffect`. Use a populated `deps` array (e.g. `[messages.length]`) to gate by meaningful changes.
229
249
  - **Concurrent rendering safe.** React 18+ concurrent features may discard render results, but the commit phase itself is synchronous, so `capture`/`apply` always run as a paired set.
230
250
  - **SSR safe.** Lifecycle methods don't run during `renderToString` / `renderToPipeableStream`, so the bridge is a no-op on the server.
251
+ - **Client Component.** The package entry starts with `'use client'`. In the Next.js App Router, render it from a Client Component. `capture` and `apply` are functions, so a Server Component cannot pass them into this wrapper.
231
252
  - **React 19.** Class components and `getSnapshotBeforeUpdate` are not deprecated in React 19. This library remains compatible.
232
253
 
233
254
  ## License
package/dist/index.cjs CHANGED
@@ -1,9 +1,8 @@
1
+ 'use client';
1
2
  'use strict';
2
3
 
3
4
  var react = require('react');
4
5
 
5
- // src/SnapshotBeforeUpdate.tsx
6
-
7
6
  // src/_utils/checkIsDepsEqual.ts
8
7
  function checkIsDepsEqual(prev, next) {
9
8
  if (prev === void 0 || next === void 0) return false;
@@ -22,11 +21,11 @@ var SnapshotBeforeUpdate = class extends react.Component {
22
21
  if (this.props.enabled === false) return SKIP;
23
22
  if (this.props.deps !== void 0 && checkIsDepsEqual_default(prevProps.deps, this.props.deps))
24
23
  return SKIP;
25
- return this.props.capture();
24
+ return { value: this.props.capture() };
26
25
  }
27
26
  componentDidUpdate(_prevProps, _prevState, snapshot) {
28
27
  if (snapshot === SKIP) return;
29
- this.props.apply(snapshot);
28
+ this.props.apply(snapshot.value);
30
29
  }
31
30
  render() {
32
31
  return this.props.children ?? null;
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/_utils/checkIsDepsEqual.ts","../src/SnapshotBeforeUpdate.tsx"],"names":["Component"],"mappings":";;;;;;;AAAA,SAAS,gBAAA,CACP,MACA,IAAA,EACS;AACT,EAAA,IAAI,IAAA,KAAS,MAAA,IAAa,IAAA,KAAS,MAAA,EAAW,OAAO,KAAA;AACrD,EAAA,IAAI,IAAA,CAAK,MAAA,KAAW,IAAA,CAAK,MAAA,EAAQ,OAAO,KAAA;AACxC,EAAA,KAAA,IAAS,CAAA,GAAI,CAAA,EAAG,CAAA,GAAI,IAAA,CAAK,QAAQ,CAAA,EAAA,EAAK;AACpC,IAAA,IAAI,CAAC,MAAA,CAAO,EAAA,CAAG,IAAA,CAAK,CAAC,GAAG,IAAA,CAAK,CAAC,CAAC,CAAA,EAAG,OAAO,KAAA;AAAA,EAC3C;AACA,EAAA,OAAO,IAAA;AACT;AAEA,IAAO,wBAAA,GAAQ,gBAAA;;;ACHf,IAAM,IAAA,0BAAc,4BAA4B,CAAA;AAkDzC,IAAM,oBAAA,GAAN,cAAgDA,eAAA,CAIrD;AAAA,EACS,wBACP,SAAA,EACU;AACV,IAAA,IAAI,IAAA,CAAK,KAAA,CAAM,OAAA,KAAY,KAAA,EAAO,OAAO,IAAA;AAEzC,IAAA,IACE,IAAA,CAAK,MAAM,IAAA,KAAS,MAAA,IACpB,yBAAiB,SAAA,CAAU,IAAA,EAAM,IAAA,CAAK,KAAA,CAAM,IAAI,CAAA;AAEhD,MAAA,OAAO,IAAA;AAET,IAAA,OAAO,IAAA,CAAK,MAAM,OAAA,EAAQ;AAAA,EAC5B;AAAA,EAES,kBAAA,CACP,UAAA,EACA,UAAA,EACA,QAAA,EACM;AACN,IAAA,IAAI,aAAa,IAAA,EAAM;AAEvB,IAAA,IAAA,CAAK,KAAA,CAAM,MAAM,QAAQ,CAAA;AAAA,EAC3B;AAAA,EAES,MAAA,GAAoB;AAC3B,IAAA,OAAO,IAAA,CAAK,MAAM,QAAA,IAAY,IAAA;AAAA,EAChC;AACF","file":"index.cjs","sourcesContent":["function checkIsDepsEqual(\n prev: readonly unknown[] | undefined,\n next: readonly unknown[] | undefined,\n): boolean {\n if (prev === undefined || next === undefined) return false;\n if (prev.length !== next.length) return false;\n for (let i = 0; i < prev.length; i++) {\n if (!Object.is(prev[i], next[i])) return false;\n }\n return true;\n}\n\nexport default checkIsDepsEqual;\n","import type { ReactNode } from \"react\";\nimport { Component } from \"react\";\nimport checkIsDepsEqual from \"./_utils/checkIsDepsEqual\";\n\ntype EmptyState = Record<string, never>;\n\n/**\n * Symbol used to indicate that the snapshot should be skipped.\n */\nconst SKIP = Symbol(\"react-snapshot-bridge-skip\");\ntype Skip = typeof SKIP;\n\nexport interface SnapshotBeforeUpdateProps<T> {\n /**\n * Runs synchronously **before** the DOM is mutated for this commit.\n * Whatever you return here is forwarded to {@link SnapshotBeforeUpdateProps.apply}.\n */\n capture: () => T;\n\n /**\n * Runs synchronously **after** the DOM is mutated, before the browser paints.\n * Receives the value previously returned from {@link SnapshotBeforeUpdateProps.capture}.\n */\n apply: (snapshot: T) => void;\n\n /**\n * When `false`, both `capture` and `apply` are skipped for that commit.\n * Evaluated against the **current** props at commit time, so toggling\n * `true` -> `false` skips both callbacks for that update, and `false` -> `true`\n * runs both as usual on the next update. Defaults to `true`.\n *\n * Updates that occur while disabled are not buffered or replayed later.\n */\n enabled?: boolean;\n\n /**\n * Optional dependency array, compared shallowly (`Object.is`) against the\n * previous commit. When provided, both `capture` and `apply` run only on the\n * commit where at least one entry changed; commits with unchanged `deps` are\n * skipped. When omitted, both callbacks run on every update (default).\n */\n deps?: readonly unknown[];\n\n /**\n * Optional children. When provided, this component acts as a wrapper and\n * renders its children. When omitted, it renders nothing and you can place\n * it as a sibling next to the elements you care about.\n */\n children?: ReactNode;\n}\n\n/**\n * Bridges React's class-only `getSnapshotBeforeUpdate` lifecycle into trees\n * built with function components.\n *\n * The component re-renders alongside its parent and uses the `getSnapshotBeforeUpdate`\n * / `componentDidUpdate` pair internally to invoke {@link SnapshotBeforeUpdateProps.capture}\n * before DOM mutation and {@link SnapshotBeforeUpdateProps.apply} immediately after.\n */\nexport class SnapshotBeforeUpdate<T = unknown> extends Component<\n SnapshotBeforeUpdateProps<T>,\n EmptyState,\n T | Skip\n> {\n override getSnapshotBeforeUpdate(\n prevProps: Readonly<SnapshotBeforeUpdateProps<T>>,\n ): T | Skip {\n if (this.props.enabled === false) return SKIP;\n\n if (\n this.props.deps !== undefined &&\n checkIsDepsEqual(prevProps.deps, this.props.deps)\n )\n return SKIP;\n\n return this.props.capture();\n }\n\n override componentDidUpdate(\n _prevProps: Readonly<SnapshotBeforeUpdateProps<T>>,\n _prevState: Readonly<EmptyState>,\n snapshot: T | Skip,\n ): void {\n if (snapshot === SKIP) return;\n\n this.props.apply(snapshot);\n }\n\n override render(): ReactNode {\n return this.props.children ?? null;\n }\n}\n"]}
1
+ {"version":3,"sources":["../src/_utils/checkIsDepsEqual.ts","../src/SnapshotBeforeUpdate.tsx"],"names":["Component"],"mappings":";;;;;AAAA,SAAS,gBAAA,CACP,MACA,IAAA,EACS;AACT,EAAA,IAAI,IAAA,KAAS,MAAA,IAAa,IAAA,KAAS,MAAA,EAAW,OAAO,KAAA;AACrD,EAAA,IAAI,IAAA,CAAK,MAAA,KAAW,IAAA,CAAK,MAAA,EAAQ,OAAO,KAAA;AACxC,EAAA,KAAA,IAAS,CAAA,GAAI,CAAA,EAAG,CAAA,GAAI,IAAA,CAAK,QAAQ,CAAA,EAAA,EAAK;AACpC,IAAA,IAAI,CAAC,MAAA,CAAO,EAAA,CAAG,IAAA,CAAK,CAAC,GAAG,IAAA,CAAK,CAAC,CAAC,CAAA,EAAG,OAAO,KAAA;AAAA,EAC3C;AACA,EAAA,OAAO,IAAA;AACT;AAEA,IAAO,wBAAA,GAAQ,gBAAA;;;ACHf,IAAM,IAAA,0BAAc,4BAA4B,CAAA;AA2DzC,IAAM,oBAAA,GAAN,cAAgDA,eAAA,CAIrD;AAAA,EACS,wBACP,SAAA,EACyB;AACzB,IAAA,IAAI,IAAA,CAAK,KAAA,CAAM,OAAA,KAAY,KAAA,EAAO,OAAO,IAAA;AAEzC,IAAA,IAAI,IAAA,CAAK,MAAM,IAAA,KAAS,MAAA,IAAa,yBAAiB,SAAA,CAAU,IAAA,EAAM,IAAA,CAAK,KAAA,CAAM,IAAI,CAAA;AACnF,MAAA,OAAO,IAAA;AAET,IAAA,OAAO,EAAE,KAAA,EAAO,IAAA,CAAK,KAAA,CAAM,SAAQ,EAAE;AAAA,EACvC;AAAA,EAES,kBAAA,CACP,UAAA,EACA,UAAA,EACA,QAAA,EACM;AACN,IAAA,IAAI,aAAa,IAAA,EAAM;AAEvB,IAAA,IAAA,CAAK,KAAA,CAAM,KAAA,CAAM,QAAA,CAAS,KAAK,CAAA;AAAA,EACjC;AAAA,EAES,MAAA,GAAoB;AAC3B,IAAA,OAAO,IAAA,CAAK,MAAM,QAAA,IAAY,IAAA;AAAA,EAChC;AACF","file":"index.cjs","sourcesContent":["function checkIsDepsEqual(\n prev: readonly unknown[] | undefined,\n next: readonly unknown[] | undefined,\n): boolean {\n if (prev === undefined || next === undefined) return false;\n if (prev.length !== next.length) return false;\n for (let i = 0; i < prev.length; i++) {\n if (!Object.is(prev[i], next[i])) return false;\n }\n return true;\n}\n\nexport default checkIsDepsEqual;\n","import type { ReactNode } from 'react';\nimport { Component } from 'react';\nimport checkIsDepsEqual from './_utils/checkIsDepsEqual';\n\ntype EmptyState = Record<string, never>;\n\n/**\n * Symbol used to indicate that the snapshot should be skipped.\n */\nconst SKIP = Symbol('react-snapshot-bridge-skip');\ntype Skip = typeof SKIP;\n\n/**\n * React logs an error when `getSnapshotBeforeUpdate` returns `undefined`.\n * Boxing the value lets `capture` return `undefined` and still forward it to `apply`.\n */\ntype BoxedSnapshot<T> = {\n readonly value: T;\n};\n\nexport interface SnapshotBeforeUpdateProps<T> {\n /**\n * Runs synchronously **before** the DOM is mutated for this commit.\n * Whatever you return here is forwarded to {@link SnapshotBeforeUpdateProps.apply}.\n * `undefined` is a valid snapshot and is forwarded unchanged.\n */\n capture: () => T;\n\n /**\n * Runs synchronously **after** the DOM is mutated, before the browser paints.\n * Receives the value previously returned from {@link SnapshotBeforeUpdateProps.capture}.\n */\n apply: (snapshot: T) => void;\n\n /**\n * When `false`, both `capture` and `apply` are skipped for that commit.\n * Evaluated against the **current** props at commit time, so toggling\n * `true` -> `false` skips both callbacks for that update, and `false` -> `true`\n * runs both as usual on the next update. Defaults to `true`.\n *\n * Updates that occur while disabled are not buffered or replayed later.\n */\n enabled?: boolean;\n\n /**\n * Optional dependency array, compared shallowly (`Object.is`) against the\n * previous commit. When provided, both `capture` and `apply` run only on the\n * commit where at least one entry changed; commits with unchanged `deps` are\n * skipped. When omitted, both callbacks run on every update (default).\n */\n deps?: readonly unknown[];\n\n /**\n * Optional children. When provided, this component acts as a wrapper and\n * renders its children. When omitted, it renders nothing and you can place\n * it as a sibling next to the elements you care about.\n */\n children?: ReactNode;\n}\n\n/**\n * Bridges React's class-only `getSnapshotBeforeUpdate` lifecycle into trees\n * built with function components.\n *\n * The component re-renders alongside its parent and uses the `getSnapshotBeforeUpdate`\n * / `componentDidUpdate` pair internally to invoke {@link SnapshotBeforeUpdateProps.capture}\n * before DOM mutation and {@link SnapshotBeforeUpdateProps.apply} immediately after.\n */\nexport class SnapshotBeforeUpdate<T = unknown> extends Component<\n SnapshotBeforeUpdateProps<T>,\n EmptyState,\n BoxedSnapshot<T> | Skip\n> {\n override getSnapshotBeforeUpdate(\n prevProps: Readonly<SnapshotBeforeUpdateProps<T>>,\n ): BoxedSnapshot<T> | Skip {\n if (this.props.enabled === false) return SKIP;\n\n if (this.props.deps !== undefined && checkIsDepsEqual(prevProps.deps, this.props.deps))\n return SKIP;\n\n return { value: this.props.capture() };\n }\n\n override componentDidUpdate(\n _prevProps: Readonly<SnapshotBeforeUpdateProps<T>>,\n _prevState: Readonly<EmptyState>,\n snapshot: BoxedSnapshot<T> | Skip,\n ): void {\n if (snapshot === SKIP) return;\n\n this.props.apply(snapshot.value);\n }\n\n override render(): ReactNode {\n return this.props.children ?? null;\n }\n}\n"]}
package/dist/index.d.cts CHANGED
@@ -6,10 +6,18 @@ type EmptyState = Record<string, never>;
6
6
  */
7
7
  declare const SKIP: unique symbol;
8
8
  type Skip = typeof SKIP;
9
+ /**
10
+ * React logs an error when `getSnapshotBeforeUpdate` returns `undefined`.
11
+ * Boxing the value lets `capture` return `undefined` and still forward it to `apply`.
12
+ */
13
+ type BoxedSnapshot<T> = {
14
+ readonly value: T;
15
+ };
9
16
  interface SnapshotBeforeUpdateProps<T> {
10
17
  /**
11
18
  * Runs synchronously **before** the DOM is mutated for this commit.
12
19
  * Whatever you return here is forwarded to {@link SnapshotBeforeUpdateProps.apply}.
20
+ * `undefined` is a valid snapshot and is forwarded unchanged.
13
21
  */
14
22
  capture: () => T;
15
23
  /**
@@ -48,9 +56,9 @@ interface SnapshotBeforeUpdateProps<T> {
48
56
  * / `componentDidUpdate` pair internally to invoke {@link SnapshotBeforeUpdateProps.capture}
49
57
  * before DOM mutation and {@link SnapshotBeforeUpdateProps.apply} immediately after.
50
58
  */
51
- declare class SnapshotBeforeUpdate<T = unknown> extends Component<SnapshotBeforeUpdateProps<T>, EmptyState, T | Skip> {
52
- getSnapshotBeforeUpdate(prevProps: Readonly<SnapshotBeforeUpdateProps<T>>): T | Skip;
53
- componentDidUpdate(_prevProps: Readonly<SnapshotBeforeUpdateProps<T>>, _prevState: Readonly<EmptyState>, snapshot: T | Skip): void;
59
+ declare class SnapshotBeforeUpdate<T = unknown> extends Component<SnapshotBeforeUpdateProps<T>, EmptyState, BoxedSnapshot<T> | Skip> {
60
+ getSnapshotBeforeUpdate(prevProps: Readonly<SnapshotBeforeUpdateProps<T>>): BoxedSnapshot<T> | Skip;
61
+ componentDidUpdate(_prevProps: Readonly<SnapshotBeforeUpdateProps<T>>, _prevState: Readonly<EmptyState>, snapshot: BoxedSnapshot<T> | Skip): void;
54
62
  render(): ReactNode;
55
63
  }
56
64
 
package/dist/index.d.ts CHANGED
@@ -6,10 +6,18 @@ type EmptyState = Record<string, never>;
6
6
  */
7
7
  declare const SKIP: unique symbol;
8
8
  type Skip = typeof SKIP;
9
+ /**
10
+ * React logs an error when `getSnapshotBeforeUpdate` returns `undefined`.
11
+ * Boxing the value lets `capture` return `undefined` and still forward it to `apply`.
12
+ */
13
+ type BoxedSnapshot<T> = {
14
+ readonly value: T;
15
+ };
9
16
  interface SnapshotBeforeUpdateProps<T> {
10
17
  /**
11
18
  * Runs synchronously **before** the DOM is mutated for this commit.
12
19
  * Whatever you return here is forwarded to {@link SnapshotBeforeUpdateProps.apply}.
20
+ * `undefined` is a valid snapshot and is forwarded unchanged.
13
21
  */
14
22
  capture: () => T;
15
23
  /**
@@ -48,9 +56,9 @@ interface SnapshotBeforeUpdateProps<T> {
48
56
  * / `componentDidUpdate` pair internally to invoke {@link SnapshotBeforeUpdateProps.capture}
49
57
  * before DOM mutation and {@link SnapshotBeforeUpdateProps.apply} immediately after.
50
58
  */
51
- declare class SnapshotBeforeUpdate<T = unknown> extends Component<SnapshotBeforeUpdateProps<T>, EmptyState, T | Skip> {
52
- getSnapshotBeforeUpdate(prevProps: Readonly<SnapshotBeforeUpdateProps<T>>): T | Skip;
53
- componentDidUpdate(_prevProps: Readonly<SnapshotBeforeUpdateProps<T>>, _prevState: Readonly<EmptyState>, snapshot: T | Skip): void;
59
+ declare class SnapshotBeforeUpdate<T = unknown> extends Component<SnapshotBeforeUpdateProps<T>, EmptyState, BoxedSnapshot<T> | Skip> {
60
+ getSnapshotBeforeUpdate(prevProps: Readonly<SnapshotBeforeUpdateProps<T>>): BoxedSnapshot<T> | Skip;
61
+ componentDidUpdate(_prevProps: Readonly<SnapshotBeforeUpdateProps<T>>, _prevState: Readonly<EmptyState>, snapshot: BoxedSnapshot<T> | Skip): void;
54
62
  render(): ReactNode;
55
63
  }
56
64
 
package/dist/index.js CHANGED
@@ -1,7 +1,6 @@
1
+ 'use client';
1
2
  import { Component } from 'react';
2
3
 
3
- // src/SnapshotBeforeUpdate.tsx
4
-
5
4
  // src/_utils/checkIsDepsEqual.ts
6
5
  function checkIsDepsEqual(prev, next) {
7
6
  if (prev === void 0 || next === void 0) return false;
@@ -20,11 +19,11 @@ var SnapshotBeforeUpdate = class extends Component {
20
19
  if (this.props.enabled === false) return SKIP;
21
20
  if (this.props.deps !== void 0 && checkIsDepsEqual_default(prevProps.deps, this.props.deps))
22
21
  return SKIP;
23
- return this.props.capture();
22
+ return { value: this.props.capture() };
24
23
  }
25
24
  componentDidUpdate(_prevProps, _prevState, snapshot) {
26
25
  if (snapshot === SKIP) return;
27
- this.props.apply(snapshot);
26
+ this.props.apply(snapshot.value);
28
27
  }
29
28
  render() {
30
29
  return this.props.children ?? null;
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/_utils/checkIsDepsEqual.ts","../src/SnapshotBeforeUpdate.tsx"],"names":[],"mappings":";;;;;AAAA,SAAS,gBAAA,CACP,MACA,IAAA,EACS;AACT,EAAA,IAAI,IAAA,KAAS,MAAA,IAAa,IAAA,KAAS,MAAA,EAAW,OAAO,KAAA;AACrD,EAAA,IAAI,IAAA,CAAK,MAAA,KAAW,IAAA,CAAK,MAAA,EAAQ,OAAO,KAAA;AACxC,EAAA,KAAA,IAAS,CAAA,GAAI,CAAA,EAAG,CAAA,GAAI,IAAA,CAAK,QAAQ,CAAA,EAAA,EAAK;AACpC,IAAA,IAAI,CAAC,MAAA,CAAO,EAAA,CAAG,IAAA,CAAK,CAAC,GAAG,IAAA,CAAK,CAAC,CAAC,CAAA,EAAG,OAAO,KAAA;AAAA,EAC3C;AACA,EAAA,OAAO,IAAA;AACT;AAEA,IAAO,wBAAA,GAAQ,gBAAA;;;ACHf,IAAM,IAAA,0BAAc,4BAA4B,CAAA;AAkDzC,IAAM,oBAAA,GAAN,cAAgD,SAAA,CAIrD;AAAA,EACS,wBACP,SAAA,EACU;AACV,IAAA,IAAI,IAAA,CAAK,KAAA,CAAM,OAAA,KAAY,KAAA,EAAO,OAAO,IAAA;AAEzC,IAAA,IACE,IAAA,CAAK,MAAM,IAAA,KAAS,MAAA,IACpB,yBAAiB,SAAA,CAAU,IAAA,EAAM,IAAA,CAAK,KAAA,CAAM,IAAI,CAAA;AAEhD,MAAA,OAAO,IAAA;AAET,IAAA,OAAO,IAAA,CAAK,MAAM,OAAA,EAAQ;AAAA,EAC5B;AAAA,EAES,kBAAA,CACP,UAAA,EACA,UAAA,EACA,QAAA,EACM;AACN,IAAA,IAAI,aAAa,IAAA,EAAM;AAEvB,IAAA,IAAA,CAAK,KAAA,CAAM,MAAM,QAAQ,CAAA;AAAA,EAC3B;AAAA,EAES,MAAA,GAAoB;AAC3B,IAAA,OAAO,IAAA,CAAK,MAAM,QAAA,IAAY,IAAA;AAAA,EAChC;AACF","file":"index.js","sourcesContent":["function checkIsDepsEqual(\n prev: readonly unknown[] | undefined,\n next: readonly unknown[] | undefined,\n): boolean {\n if (prev === undefined || next === undefined) return false;\n if (prev.length !== next.length) return false;\n for (let i = 0; i < prev.length; i++) {\n if (!Object.is(prev[i], next[i])) return false;\n }\n return true;\n}\n\nexport default checkIsDepsEqual;\n","import type { ReactNode } from \"react\";\nimport { Component } from \"react\";\nimport checkIsDepsEqual from \"./_utils/checkIsDepsEqual\";\n\ntype EmptyState = Record<string, never>;\n\n/**\n * Symbol used to indicate that the snapshot should be skipped.\n */\nconst SKIP = Symbol(\"react-snapshot-bridge-skip\");\ntype Skip = typeof SKIP;\n\nexport interface SnapshotBeforeUpdateProps<T> {\n /**\n * Runs synchronously **before** the DOM is mutated for this commit.\n * Whatever you return here is forwarded to {@link SnapshotBeforeUpdateProps.apply}.\n */\n capture: () => T;\n\n /**\n * Runs synchronously **after** the DOM is mutated, before the browser paints.\n * Receives the value previously returned from {@link SnapshotBeforeUpdateProps.capture}.\n */\n apply: (snapshot: T) => void;\n\n /**\n * When `false`, both `capture` and `apply` are skipped for that commit.\n * Evaluated against the **current** props at commit time, so toggling\n * `true` -> `false` skips both callbacks for that update, and `false` -> `true`\n * runs both as usual on the next update. Defaults to `true`.\n *\n * Updates that occur while disabled are not buffered or replayed later.\n */\n enabled?: boolean;\n\n /**\n * Optional dependency array, compared shallowly (`Object.is`) against the\n * previous commit. When provided, both `capture` and `apply` run only on the\n * commit where at least one entry changed; commits with unchanged `deps` are\n * skipped. When omitted, both callbacks run on every update (default).\n */\n deps?: readonly unknown[];\n\n /**\n * Optional children. When provided, this component acts as a wrapper and\n * renders its children. When omitted, it renders nothing and you can place\n * it as a sibling next to the elements you care about.\n */\n children?: ReactNode;\n}\n\n/**\n * Bridges React's class-only `getSnapshotBeforeUpdate` lifecycle into trees\n * built with function components.\n *\n * The component re-renders alongside its parent and uses the `getSnapshotBeforeUpdate`\n * / `componentDidUpdate` pair internally to invoke {@link SnapshotBeforeUpdateProps.capture}\n * before DOM mutation and {@link SnapshotBeforeUpdateProps.apply} immediately after.\n */\nexport class SnapshotBeforeUpdate<T = unknown> extends Component<\n SnapshotBeforeUpdateProps<T>,\n EmptyState,\n T | Skip\n> {\n override getSnapshotBeforeUpdate(\n prevProps: Readonly<SnapshotBeforeUpdateProps<T>>,\n ): T | Skip {\n if (this.props.enabled === false) return SKIP;\n\n if (\n this.props.deps !== undefined &&\n checkIsDepsEqual(prevProps.deps, this.props.deps)\n )\n return SKIP;\n\n return this.props.capture();\n }\n\n override componentDidUpdate(\n _prevProps: Readonly<SnapshotBeforeUpdateProps<T>>,\n _prevState: Readonly<EmptyState>,\n snapshot: T | Skip,\n ): void {\n if (snapshot === SKIP) return;\n\n this.props.apply(snapshot);\n }\n\n override render(): ReactNode {\n return this.props.children ?? null;\n }\n}\n"]}
1
+ {"version":3,"sources":["../src/_utils/checkIsDepsEqual.ts","../src/SnapshotBeforeUpdate.tsx"],"names":[],"mappings":";;;AAAA,SAAS,gBAAA,CACP,MACA,IAAA,EACS;AACT,EAAA,IAAI,IAAA,KAAS,MAAA,IAAa,IAAA,KAAS,MAAA,EAAW,OAAO,KAAA;AACrD,EAAA,IAAI,IAAA,CAAK,MAAA,KAAW,IAAA,CAAK,MAAA,EAAQ,OAAO,KAAA;AACxC,EAAA,KAAA,IAAS,CAAA,GAAI,CAAA,EAAG,CAAA,GAAI,IAAA,CAAK,QAAQ,CAAA,EAAA,EAAK;AACpC,IAAA,IAAI,CAAC,MAAA,CAAO,EAAA,CAAG,IAAA,CAAK,CAAC,GAAG,IAAA,CAAK,CAAC,CAAC,CAAA,EAAG,OAAO,KAAA;AAAA,EAC3C;AACA,EAAA,OAAO,IAAA;AACT;AAEA,IAAO,wBAAA,GAAQ,gBAAA;;;ACHf,IAAM,IAAA,0BAAc,4BAA4B,CAAA;AA2DzC,IAAM,oBAAA,GAAN,cAAgD,SAAA,CAIrD;AAAA,EACS,wBACP,SAAA,EACyB;AACzB,IAAA,IAAI,IAAA,CAAK,KAAA,CAAM,OAAA,KAAY,KAAA,EAAO,OAAO,IAAA;AAEzC,IAAA,IAAI,IAAA,CAAK,MAAM,IAAA,KAAS,MAAA,IAAa,yBAAiB,SAAA,CAAU,IAAA,EAAM,IAAA,CAAK,KAAA,CAAM,IAAI,CAAA;AACnF,MAAA,OAAO,IAAA;AAET,IAAA,OAAO,EAAE,KAAA,EAAO,IAAA,CAAK,KAAA,CAAM,SAAQ,EAAE;AAAA,EACvC;AAAA,EAES,kBAAA,CACP,UAAA,EACA,UAAA,EACA,QAAA,EACM;AACN,IAAA,IAAI,aAAa,IAAA,EAAM;AAEvB,IAAA,IAAA,CAAK,KAAA,CAAM,KAAA,CAAM,QAAA,CAAS,KAAK,CAAA;AAAA,EACjC;AAAA,EAES,MAAA,GAAoB;AAC3B,IAAA,OAAO,IAAA,CAAK,MAAM,QAAA,IAAY,IAAA;AAAA,EAChC;AACF","file":"index.js","sourcesContent":["function checkIsDepsEqual(\n prev: readonly unknown[] | undefined,\n next: readonly unknown[] | undefined,\n): boolean {\n if (prev === undefined || next === undefined) return false;\n if (prev.length !== next.length) return false;\n for (let i = 0; i < prev.length; i++) {\n if (!Object.is(prev[i], next[i])) return false;\n }\n return true;\n}\n\nexport default checkIsDepsEqual;\n","import type { ReactNode } from 'react';\nimport { Component } from 'react';\nimport checkIsDepsEqual from './_utils/checkIsDepsEqual';\n\ntype EmptyState = Record<string, never>;\n\n/**\n * Symbol used to indicate that the snapshot should be skipped.\n */\nconst SKIP = Symbol('react-snapshot-bridge-skip');\ntype Skip = typeof SKIP;\n\n/**\n * React logs an error when `getSnapshotBeforeUpdate` returns `undefined`.\n * Boxing the value lets `capture` return `undefined` and still forward it to `apply`.\n */\ntype BoxedSnapshot<T> = {\n readonly value: T;\n};\n\nexport interface SnapshotBeforeUpdateProps<T> {\n /**\n * Runs synchronously **before** the DOM is mutated for this commit.\n * Whatever you return here is forwarded to {@link SnapshotBeforeUpdateProps.apply}.\n * `undefined` is a valid snapshot and is forwarded unchanged.\n */\n capture: () => T;\n\n /**\n * Runs synchronously **after** the DOM is mutated, before the browser paints.\n * Receives the value previously returned from {@link SnapshotBeforeUpdateProps.capture}.\n */\n apply: (snapshot: T) => void;\n\n /**\n * When `false`, both `capture` and `apply` are skipped for that commit.\n * Evaluated against the **current** props at commit time, so toggling\n * `true` -> `false` skips both callbacks for that update, and `false` -> `true`\n * runs both as usual on the next update. Defaults to `true`.\n *\n * Updates that occur while disabled are not buffered or replayed later.\n */\n enabled?: boolean;\n\n /**\n * Optional dependency array, compared shallowly (`Object.is`) against the\n * previous commit. When provided, both `capture` and `apply` run only on the\n * commit where at least one entry changed; commits with unchanged `deps` are\n * skipped. When omitted, both callbacks run on every update (default).\n */\n deps?: readonly unknown[];\n\n /**\n * Optional children. When provided, this component acts as a wrapper and\n * renders its children. When omitted, it renders nothing and you can place\n * it as a sibling next to the elements you care about.\n */\n children?: ReactNode;\n}\n\n/**\n * Bridges React's class-only `getSnapshotBeforeUpdate` lifecycle into trees\n * built with function components.\n *\n * The component re-renders alongside its parent and uses the `getSnapshotBeforeUpdate`\n * / `componentDidUpdate` pair internally to invoke {@link SnapshotBeforeUpdateProps.capture}\n * before DOM mutation and {@link SnapshotBeforeUpdateProps.apply} immediately after.\n */\nexport class SnapshotBeforeUpdate<T = unknown> extends Component<\n SnapshotBeforeUpdateProps<T>,\n EmptyState,\n BoxedSnapshot<T> | Skip\n> {\n override getSnapshotBeforeUpdate(\n prevProps: Readonly<SnapshotBeforeUpdateProps<T>>,\n ): BoxedSnapshot<T> | Skip {\n if (this.props.enabled === false) return SKIP;\n\n if (this.props.deps !== undefined && checkIsDepsEqual(prevProps.deps, this.props.deps))\n return SKIP;\n\n return { value: this.props.capture() };\n }\n\n override componentDidUpdate(\n _prevProps: Readonly<SnapshotBeforeUpdateProps<T>>,\n _prevState: Readonly<EmptyState>,\n snapshot: BoxedSnapshot<T> | Skip,\n ): void {\n if (snapshot === SKIP) return;\n\n this.props.apply(snapshot.value);\n }\n\n override render(): ReactNode {\n return this.props.children ?? null;\n }\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-snapshot-bridge",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "Use React's class-only `getSnapshotBeforeUpdate` lifecycle from function components.",
5
5
  "keywords": [
6
6
  "react",