react-snapshot-bridge 1.1.2 → 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,13 +163,14 @@ 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. |
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. |
153
174
  | `children` | `React.ReactNode` | no | Optional. When provided, rendered as-is (wrapper pattern). When omitted, renders nothing. |
154
175
 
155
176
  The generic `T` is inferred from `capture`'s return type — you usually don't need to specify it.
@@ -224,8 +245,10 @@ The captured value is forwarded to `componentDidUpdate`, which calls `apply`. Fr
224
245
  - **Updates run before DOM mutation for that commit.** After mount, whenever the bridge commits an update, `capture` runs in the **before mutation** phase: before the DOM reflects that commit's output and before the browser paints that update. That is the timing slot this library occupies (see [How it works](#how-it-works)).
225
246
  - **Requires a re-render in the right place.** If a `React.memo` (or `shouldComponentUpdate`) ancestor short-circuits the render, or the bridge is placed as a sibling of a different component that updates on its own, neither callback will fire. See [Where to place it](#where-to-place-it).
226
247
  - **`enabled={false}` does not buffer updates.** Updates that occur while disabled are silently dropped. There is no "catch up" call when you re-enable the bridge — the next regular update is what triggers `capture`/`apply` again.
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.
227
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.
228
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.
229
252
  - **React 19.** Class components and `getSnapshotBeforeUpdate` are not deprecated in React 19. This library remains compatible.
230
253
 
231
254
  ## License
package/dist/index.cjs CHANGED
@@ -1,20 +1,31 @@
1
+ 'use client';
1
2
  'use strict';
2
3
 
3
4
  var react = require('react');
4
5
 
6
+ // src/_utils/checkIsDepsEqual.ts
7
+ function checkIsDepsEqual(prev, next) {
8
+ if (prev === void 0 || next === void 0) return false;
9
+ if (prev.length !== next.length) return false;
10
+ for (let i = 0; i < prev.length; i++) {
11
+ if (!Object.is(prev[i], next[i])) return false;
12
+ }
13
+ return true;
14
+ }
15
+ var checkIsDepsEqual_default = checkIsDepsEqual;
16
+
5
17
  // src/SnapshotBeforeUpdate.tsx
6
18
  var SKIP = /* @__PURE__ */ Symbol("react-snapshot-bridge-skip");
7
19
  var SnapshotBeforeUpdate = class extends react.Component {
8
- getSnapshotBeforeUpdate() {
9
- if (this.props.enabled === false) {
20
+ getSnapshotBeforeUpdate(prevProps) {
21
+ if (this.props.enabled === false) return SKIP;
22
+ if (this.props.deps !== void 0 && checkIsDepsEqual_default(prevProps.deps, this.props.deps))
10
23
  return SKIP;
11
- }
12
- return this.props.capture();
24
+ return { value: this.props.capture() };
13
25
  }
14
26
  componentDidUpdate(_prevProps, _prevState, snapshot) {
15
- if (this.props.enabled === false) return;
16
27
  if (snapshot === SKIP) return;
17
- this.props.apply(snapshot);
28
+ this.props.apply(snapshot.value);
18
29
  }
19
30
  render() {
20
31
  return this.props.children ?? null;
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/SnapshotBeforeUpdate.tsx"],"names":["Component"],"mappings":";;;;;AAQA,IAAM,IAAA,0BAAc,4BAA4B,CAAA;AA0CzC,IAAM,oBAAA,GAAN,cAAgDA,eAAA,CAGrD;AAAA,EACS,uBAAA,GAA6B;AACpC,IAAA,IAAI,IAAA,CAAK,KAAA,CAAM,OAAA,KAAY,KAAA,EAAO;AAChC,MAAA,OAAO,IAAA;AAAA,IACT;AACA,IAAA,OAAO,IAAA,CAAK,MAAM,OAAA,EAAQ;AAAA,EAC5B;AAAA,EAES,kBAAA,CACP,UAAA,EACA,UAAA,EACA,QAAA,EACM;AACN,IAAA,IAAI,IAAA,CAAK,KAAA,CAAM,OAAA,KAAY,KAAA,EAAO;AAClC,IAAA,IAAK,aAAiC,IAAA,EAAM;AAE5C,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":["import type { ReactNode } from 'react';\nimport { Component } from 'react';\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 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> {\n override getSnapshotBeforeUpdate(): T {\n if (this.props.enabled === false) {\n return SKIP as unknown as T;\n }\n return this.props.capture();\n }\n\n override componentDidUpdate(\n _prevProps: Readonly<SnapshotBeforeUpdateProps<T>>,\n _prevState: Readonly<EmptyState>,\n snapshot: T,\n ): void {\n if (this.props.enabled === false) return;\n if ((snapshot as unknown as Skip) === 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
@@ -1,10 +1,23 @@
1
1
  import { Component, ReactNode } from 'react';
2
2
 
3
3
  type EmptyState = Record<string, never>;
4
+ /**
5
+ * Symbol used to indicate that the snapshot should be skipped.
6
+ */
7
+ declare const SKIP: unique symbol;
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
+ };
4
16
  interface SnapshotBeforeUpdateProps<T> {
5
17
  /**
6
18
  * Runs synchronously **before** the DOM is mutated for this commit.
7
19
  * Whatever you return here is forwarded to {@link SnapshotBeforeUpdateProps.apply}.
20
+ * `undefined` is a valid snapshot and is forwarded unchanged.
8
21
  */
9
22
  capture: () => T;
10
23
  /**
@@ -21,6 +34,13 @@ interface SnapshotBeforeUpdateProps<T> {
21
34
  * Updates that occur while disabled are not buffered or replayed later.
22
35
  */
23
36
  enabled?: boolean;
37
+ /**
38
+ * Optional dependency array, compared shallowly (`Object.is`) against the
39
+ * previous commit. When provided, both `capture` and `apply` run only on the
40
+ * commit where at least one entry changed; commits with unchanged `deps` are
41
+ * skipped. When omitted, both callbacks run on every update (default).
42
+ */
43
+ deps?: readonly unknown[];
24
44
  /**
25
45
  * Optional children. When provided, this component acts as a wrapper and
26
46
  * renders its children. When omitted, it renders nothing and you can place
@@ -36,9 +56,9 @@ interface SnapshotBeforeUpdateProps<T> {
36
56
  * / `componentDidUpdate` pair internally to invoke {@link SnapshotBeforeUpdateProps.capture}
37
57
  * before DOM mutation and {@link SnapshotBeforeUpdateProps.apply} immediately after.
38
58
  */
39
- declare class SnapshotBeforeUpdate<T = unknown> extends Component<SnapshotBeforeUpdateProps<T>, EmptyState> {
40
- getSnapshotBeforeUpdate(): T;
41
- componentDidUpdate(_prevProps: Readonly<SnapshotBeforeUpdateProps<T>>, _prevState: Readonly<EmptyState>, snapshot: T): 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;
42
62
  render(): ReactNode;
43
63
  }
44
64
 
package/dist/index.d.ts CHANGED
@@ -1,10 +1,23 @@
1
1
  import { Component, ReactNode } from 'react';
2
2
 
3
3
  type EmptyState = Record<string, never>;
4
+ /**
5
+ * Symbol used to indicate that the snapshot should be skipped.
6
+ */
7
+ declare const SKIP: unique symbol;
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
+ };
4
16
  interface SnapshotBeforeUpdateProps<T> {
5
17
  /**
6
18
  * Runs synchronously **before** the DOM is mutated for this commit.
7
19
  * Whatever you return here is forwarded to {@link SnapshotBeforeUpdateProps.apply}.
20
+ * `undefined` is a valid snapshot and is forwarded unchanged.
8
21
  */
9
22
  capture: () => T;
10
23
  /**
@@ -21,6 +34,13 @@ interface SnapshotBeforeUpdateProps<T> {
21
34
  * Updates that occur while disabled are not buffered or replayed later.
22
35
  */
23
36
  enabled?: boolean;
37
+ /**
38
+ * Optional dependency array, compared shallowly (`Object.is`) against the
39
+ * previous commit. When provided, both `capture` and `apply` run only on the
40
+ * commit where at least one entry changed; commits with unchanged `deps` are
41
+ * skipped. When omitted, both callbacks run on every update (default).
42
+ */
43
+ deps?: readonly unknown[];
24
44
  /**
25
45
  * Optional children. When provided, this component acts as a wrapper and
26
46
  * renders its children. When omitted, it renders nothing and you can place
@@ -36,9 +56,9 @@ interface SnapshotBeforeUpdateProps<T> {
36
56
  * / `componentDidUpdate` pair internally to invoke {@link SnapshotBeforeUpdateProps.capture}
37
57
  * before DOM mutation and {@link SnapshotBeforeUpdateProps.apply} immediately after.
38
58
  */
39
- declare class SnapshotBeforeUpdate<T = unknown> extends Component<SnapshotBeforeUpdateProps<T>, EmptyState> {
40
- getSnapshotBeforeUpdate(): T;
41
- componentDidUpdate(_prevProps: Readonly<SnapshotBeforeUpdateProps<T>>, _prevState: Readonly<EmptyState>, snapshot: T): 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;
42
62
  render(): ReactNode;
43
63
  }
44
64
 
package/dist/index.js CHANGED
@@ -1,18 +1,29 @@
1
+ 'use client';
1
2
  import { Component } from 'react';
2
3
 
4
+ // src/_utils/checkIsDepsEqual.ts
5
+ function checkIsDepsEqual(prev, next) {
6
+ if (prev === void 0 || next === void 0) return false;
7
+ if (prev.length !== next.length) return false;
8
+ for (let i = 0; i < prev.length; i++) {
9
+ if (!Object.is(prev[i], next[i])) return false;
10
+ }
11
+ return true;
12
+ }
13
+ var checkIsDepsEqual_default = checkIsDepsEqual;
14
+
3
15
  // src/SnapshotBeforeUpdate.tsx
4
16
  var SKIP = /* @__PURE__ */ Symbol("react-snapshot-bridge-skip");
5
17
  var SnapshotBeforeUpdate = class extends Component {
6
- getSnapshotBeforeUpdate() {
7
- if (this.props.enabled === false) {
18
+ getSnapshotBeforeUpdate(prevProps) {
19
+ if (this.props.enabled === false) return SKIP;
20
+ if (this.props.deps !== void 0 && checkIsDepsEqual_default(prevProps.deps, this.props.deps))
8
21
  return SKIP;
9
- }
10
- return this.props.capture();
22
+ return { value: this.props.capture() };
11
23
  }
12
24
  componentDidUpdate(_prevProps, _prevState, snapshot) {
13
- if (this.props.enabled === false) return;
14
25
  if (snapshot === SKIP) return;
15
- this.props.apply(snapshot);
26
+ this.props.apply(snapshot.value);
16
27
  }
17
28
  render() {
18
29
  return this.props.children ?? null;
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/SnapshotBeforeUpdate.tsx"],"names":[],"mappings":";;;AAQA,IAAM,IAAA,0BAAc,4BAA4B,CAAA;AA0CzC,IAAM,oBAAA,GAAN,cAAgD,SAAA,CAGrD;AAAA,EACS,uBAAA,GAA6B;AACpC,IAAA,IAAI,IAAA,CAAK,KAAA,CAAM,OAAA,KAAY,KAAA,EAAO;AAChC,MAAA,OAAO,IAAA;AAAA,IACT;AACA,IAAA,OAAO,IAAA,CAAK,MAAM,OAAA,EAAQ;AAAA,EAC5B;AAAA,EAES,kBAAA,CACP,UAAA,EACA,UAAA,EACA,QAAA,EACM;AACN,IAAA,IAAI,IAAA,CAAK,KAAA,CAAM,OAAA,KAAY,KAAA,EAAO;AAClC,IAAA,IAAK,aAAiC,IAAA,EAAM;AAE5C,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":["import type { ReactNode } from 'react';\nimport { Component } from 'react';\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 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> {\n override getSnapshotBeforeUpdate(): T {\n if (this.props.enabled === false) {\n return SKIP as unknown as T;\n }\n return this.props.capture();\n }\n\n override componentDidUpdate(\n _prevProps: Readonly<SnapshotBeforeUpdateProps<T>>,\n _prevState: Readonly<EmptyState>,\n snapshot: T,\n ): void {\n if (this.props.enabled === false) return;\n if ((snapshot as unknown as Skip) === 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.1.2",
3
+ "version": "1.2.1",
4
4
  "description": "Use React's class-only `getSnapshotBeforeUpdate` lifecycle from function components.",
5
5
  "keywords": [
6
6
  "react",