@grimoire-rs/indexer 0.3.2 → 0.3.3

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.
@@ -120,12 +120,21 @@ function CopyButton({ command, variant = "default", name, }) {
120
120
  // hydrates in the browser, so importing the build-time payload here would
121
121
  // ship the whole catalog twice.
122
122
  export default function Catalog({ packages, vscodeExtension, }) {
123
- // Seeded from `?q=…` on the very first render, not from an effect: a
124
- // keyword chip on a package page links here, and filtering one render late
125
- // means painting the full catalog and then collapsing it. The server has
126
- // no `location`, so it renders the unfiltered list — which is what a
127
- // crawler and a `?q=`-less visitor should both get.
128
- const [query, setQuery] = useState(() => typeof location === "undefined" ? "" : (new URLSearchParams(location.search).get("q") ?? ""));
123
+ // Empty on the first render, ALWAYS `?q=…` is applied a beat later, in
124
+ // the layout effect below. This is not a style preference, it is the one
125
+ // rule this island has to obey.
126
+ //
127
+ // Preact does not diff props while hydrating; its own source says so, and
128
+ // only re-applies props whose value is a function. Text children *are*
129
+ // diffed. So when the first client render disagrees with the server's, you
130
+ // get cards whose text is right and whose every attribute belongs to
131
+ // whichever package the server put at that position: one package's logo
132
+ // over another's name, and a link that opens the wrong page. Nothing
133
+ // throws. This used to seed from `location` here, and did exactly that.
134
+ const [query, setQuery] = useState("");
135
+ // Whether the URL's query has been applied. Gates the reveal below, so the
136
+ // catalog is never unhidden while it still shows the unfiltered list.
137
+ const [seeded, setSeeded] = useState(false);
129
138
  const [kind, setKind] = useState(null);
130
139
  const [sort, setSort] = useState("name");
131
140
  // Deprecated packages are hidden until asked for: a retired package is
@@ -168,19 +177,29 @@ export default function Catalog({ packages, vscodeExtension, }) {
168
177
  behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
169
178
  });
170
179
  };
180
+ // Apply `?q=…`, now that hydration has matched the server's markup and
181
+ // Preact owns the tree. A layout effect rather than a plain one: the
182
+ // resulting render must land before the browser paints, or a `?q=` visitor
183
+ // sees the whole catalog flash past on the way to their results.
184
+ useLayoutEffect(() => {
185
+ setQuery(new URLSearchParams(location.search).get("q") ?? "");
186
+ setSeeded(true);
187
+ }, []);
171
188
  // Base.astro hides the catalog before first paint when the URL carries a
172
- // query. Reveal it once this render the filtered one has hit the DOM.
173
- // A layout effect, so the reveal lands in the same frame as the content.
189
+ // query. Reveal it only once the filtered render is in the DOMkeyed on
190
+ // `seeded`, so the unfiltered first render is never the one revealed.
174
191
  useLayoutEffect(() => {
175
- // Hydration attaches handlers but does not diff props against the server
176
- // markup, so the box the server rendered empty stays empty even though
177
- // this render filtered on `query`. Written straight to the DOM, which is
178
- // what the vnode already claims.
192
+ if (!seeded)
193
+ return;
194
+ // The input's `value` prop was skipped during hydration for the same
195
+ // reason every other prop was, and the render that applied the query is
196
+ // a normal diff — but write it anyway: this effect is also what runs on
197
+ // a `?q=`-less load, where no second render is queued at all.
179
198
  const input = searchRef.current;
180
199
  if (input && input.value !== query)
181
200
  input.value = query;
182
201
  delete document.documentElement.dataset.query;
183
- }, []);
202
+ }, [seeded]);
184
203
  // `/` jumps to the search box, the convention every package registry
185
204
  // shares. Bound on the document so it works wherever the reader is.
186
205
  useEffect(() => {
@@ -187,14 +187,21 @@ export default function Catalog({
187
187
  packages: CatalogPackage[];
188
188
  vscodeExtension: string | null;
189
189
  }) {
190
- // Seeded from `?q=…` on the very first render, not from an effect: a
191
- // keyword chip on a package page links here, and filtering one render late
192
- // means painting the full catalog and then collapsing it. The server has
193
- // no `location`, so it renders the unfiltered list — which is what a
194
- // crawler and a `?q=`-less visitor should both get.
195
- const [query, setQuery] = useState(() =>
196
- typeof location === "undefined" ? "" : (new URLSearchParams(location.search).get("q") ?? ""),
197
- );
190
+ // Empty on the first render, ALWAYS `?q=…` is applied a beat later, in
191
+ // the layout effect below. This is not a style preference, it is the one
192
+ // rule this island has to obey.
193
+ //
194
+ // Preact does not diff props while hydrating; its own source says so, and
195
+ // only re-applies props whose value is a function. Text children *are*
196
+ // diffed. So when the first client render disagrees with the server's, you
197
+ // get cards whose text is right and whose every attribute belongs to
198
+ // whichever package the server put at that position: one package's logo
199
+ // over another's name, and a link that opens the wrong page. Nothing
200
+ // throws. This used to seed from `location` here, and did exactly that.
201
+ const [query, setQuery] = useState("");
202
+ // Whether the URL's query has been applied. Gates the reveal below, so the
203
+ // catalog is never unhidden while it still shows the unfiltered list.
204
+ const [seeded, setSeeded] = useState(false);
198
205
  const [kind, setKind] = useState<string | null>(null);
199
206
  const [sort, setSort] = useState<Sort>("name");
200
207
  // Deprecated packages are hidden until asked for: a retired package is
@@ -240,18 +247,28 @@ export default function Catalog({
240
247
  });
241
248
  };
242
249
 
250
+ // Apply `?q=…`, now that hydration has matched the server's markup and
251
+ // Preact owns the tree. A layout effect rather than a plain one: the
252
+ // resulting render must land before the browser paints, or a `?q=` visitor
253
+ // sees the whole catalog flash past on the way to their results.
254
+ useLayoutEffect(() => {
255
+ setQuery(new URLSearchParams(location.search).get("q") ?? "");
256
+ setSeeded(true);
257
+ }, []);
258
+
243
259
  // Base.astro hides the catalog before first paint when the URL carries a
244
- // query. Reveal it once this render the filtered one has hit the DOM.
245
- // A layout effect, so the reveal lands in the same frame as the content.
260
+ // query. Reveal it only once the filtered render is in the DOMkeyed on
261
+ // `seeded`, so the unfiltered first render is never the one revealed.
246
262
  useLayoutEffect(() => {
247
- // Hydration attaches handlers but does not diff props against the server
248
- // markup, so the box the server rendered empty stays empty even though
249
- // this render filtered on `query`. Written straight to the DOM, which is
250
- // what the vnode already claims.
263
+ if (!seeded) return;
264
+ // The input's `value` prop was skipped during hydration for the same
265
+ // reason every other prop was, and the render that applied the query is
266
+ // a normal diff but write it anyway: this effect is also what runs on
267
+ // a `?q=`-less load, where no second render is queued at all.
251
268
  const input = searchRef.current;
252
269
  if (input && input.value !== query) input.value = query;
253
270
  delete document.documentElement.dataset.query;
254
- }, []);
271
+ }, [seeded]);
255
272
 
256
273
  // `/` jumps to the search box, the convention every package registry
257
274
  // shares. Bound on the document so it works wherever the reader is.
@@ -68,9 +68,10 @@ function repoLabel(url: string): string {
68
68
  // package page) means the catalog the server rendered — every package,
69
69
  // empty search box — is the wrong one. Flag it so CSS holds the
70
70
  // catalog back rather than showing the full list and collapsing it a
71
- // moment later. The island drops the flag on its first render, which
72
- // is already the filtered one; the timeout is the failsafe for an
73
- // island that never hydrates, where a flash beats a blank page.
71
+ // moment later. The island drops the flag once it has applied the
72
+ // query — which is the render after hydration, never the hydrating one
73
+ // itself; the timeout is the failsafe for an island that never
74
+ // hydrates, where a flash beats a blank page.
74
75
  if (new URLSearchParams(location.search).get("q")) {
75
76
  document.documentElement.dataset.query = "";
76
77
  setTimeout(() => delete document.documentElement.dataset.query, 3000);
@@ -864,8 +865,10 @@ function repoLabel(url: string): string {
864
865
  search box, so painting it and then collapsing to the match is a
865
866
  flash of the wrong answer. `visibility` rather than `display` —
866
867
  the space stays reserved, so the footer does not jump either. The
867
- attribute is set before first paint and dropped by the island's
868
- first render, which is already the filtered one. */
868
+ attribute is set before first paint and dropped once the island
869
+ has applied the query the render after hydration, because
870
+ hydrating against markup that disagrees with the server silently
871
+ mismatches every attribute (see Catalog.tsx). */
869
872
  :root[data-query] .catalog {
870
873
  visibility: hidden;
871
874
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grimoire-rs/indexer",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "CLI + Astro integration for running your own grim package index",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -38,6 +38,7 @@
38
38
  "@vitest/coverage-v8": "^4.1.10",
39
39
  "eslint": "^10.8.0",
40
40
  "js-yaml": "^4.1.0",
41
+ "jsdom": "^29.1.1",
41
42
  "typescript": "^6.0.3",
42
43
  "typescript-eslint": "^8.65.0",
43
44
  "vitest": "^4.1.10"