houdini-react 2.1.0 → 2.1.2

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/bin/houdini-react CHANGED
@@ -125,6 +125,8 @@ if (PLATFORM_OVERRIDE === 'wasm') {
125
125
  }
126
126
 
127
127
  // --- Native binary path ---
128
+ const attempted = [];
129
+
128
130
  function getBinaryPath() {
129
131
  if (MANUAL_BINARY_PATH && fs.existsSync(MANUAL_BINARY_PATH)) {
130
132
  return MANUAL_BINARY_PATH;
@@ -138,36 +140,82 @@ function getBinaryPath() {
138
140
  process.stderr.write(`[houdini-react] Unknown platform "${PLATFORM_OVERRIDE}". Valid values: ${Object.keys(BINARY_DISTRIBUTION_PACKAGES).join(', ')}, wasm\n`);
139
141
  process.exit(1);
140
142
  }
141
- return path.join(__dirname, binaryName);
143
+ return path.join(__dirname, '..', binaryName);
142
144
  }
143
145
 
146
+ // module resolution from the shim's own location: real installs, and any
147
+ // environment that provides NODE_PATH (pnpm scripts do)
144
148
  try {
145
149
  const platformPackagePath = require.resolve(`${platformSpecificPackageName}/package.json`);
146
150
  return path.join(path.dirname(platformPackagePath), 'bin', binaryName);
147
151
  } catch {
148
- const siblingPath = path.join(__dirname, '..', platformSpecificPackageName, 'bin', binaryName);
149
- if (fs.existsSync(siblingPath)) return siblingPath;
150
-
151
- const pnpmMatch = __dirname.match(/(.+\/node_modules\/)\.pnpm\/[^/]+\/node_modules\//);
152
- if (pnpmMatch) {
153
- const pnpmDir = path.join(pnpmMatch[1], '.pnpm');
154
- try {
155
- const packageJSON = require(path.join(__dirname, '..', 'package.json'));
156
- const entry = `${platformSpecificPackageName}@${packageJSON.version}`;
157
- const found = fs.readdirSync(pnpmDir).find(e => e === entry);
158
- if (found) {
159
- const p = path.join(pnpmDir, found, 'node_modules', platformSpecificPackageName, 'bin', binaryName);
160
- if (fs.existsSync(p)) return p;
161
- }
162
- } catch {}
163
- }
152
+ attempted.push(`require.resolve('${platformSpecificPackageName}') from ${__dirname}`);
153
+ }
154
+
155
+ // module resolution from the invoking project: a linked development setup puts
156
+ // the platform package in the project's node_modules, which the walk from the
157
+ // shim's realpath (inside the linked repo) never visits
158
+ try {
159
+ const platformPackagePath = require.resolve(`${platformSpecificPackageName}/package.json`, {
160
+ paths: [process.cwd()],
161
+ });
162
+ return path.join(path.dirname(platformPackagePath), 'bin', binaryName);
163
+ } catch {
164
+ attempted.push(`require.resolve('${platformSpecificPackageName}') from ${process.cwd()}`);
165
+ }
164
166
 
165
- return path.join(__dirname, binaryName);
167
+ // flat node_modules: the platform package next to this one
168
+ const siblingPath = path.join(__dirname, '..', platformSpecificPackageName, 'bin', binaryName);
169
+ if (fs.existsSync(siblingPath)) return siblingPath;
170
+ attempted.push(siblingPath);
171
+
172
+ // monorepo build layout: the shim lives at <pkg>/build/houdini-react/bin and the
173
+ // platform package at <pkg>/build/<platform package>
174
+ const buildSiblingPath = path.join(__dirname, '..', '..', platformSpecificPackageName, 'bin', binaryName);
175
+ if (fs.existsSync(buildSiblingPath)) return buildSiblingPath;
176
+ attempted.push(buildSiblingPath);
177
+
178
+ const pnpmMatch = __dirname.match(/(.+\/node_modules\/)\.pnpm\/[^/]+\/node_modules\//);
179
+ if (pnpmMatch) {
180
+ const pnpmDir = path.join(pnpmMatch[1], '.pnpm');
181
+ try {
182
+ const packageJSON = require(path.join(__dirname, '..', 'package.json'));
183
+ const entry = `${platformSpecificPackageName}@${packageJSON.version}`;
184
+ const found = fs.readdirSync(pnpmDir).find(e => e === entry);
185
+ if (found) {
186
+ const p = path.join(pnpmDir, found, 'node_modules', platformSpecificPackageName, 'bin', binaryName);
187
+ if (fs.existsSync(p)) return p;
188
+ }
189
+ attempted.push(path.join(pnpmDir, entry, 'node_modules', platformSpecificPackageName, 'bin', binaryName));
190
+ } catch {}
166
191
  }
192
+
193
+ // the binary postInstall downloads into the package root when no platform
194
+ // package could be installed (the shim lives in bin/, one level down)
195
+ return path.join(__dirname, '..', binaryName);
196
+ }
197
+
198
+ // Refuse to exec ourselves: when every resolution attempt fails, the final
199
+ // candidate (the postInstall download location) can be this very script — exec'ing
200
+ // it recurses forever, forking a new node per iteration until the machine chokes.
201
+ // A missing binary must be a loud error, never a spawn loop.
202
+ const binaryPath = getBinaryPath();
203
+ let realBinaryPath = null;
204
+ try {
205
+ realBinaryPath = fs.realpathSync(binaryPath);
206
+ } catch {}
207
+ if (!realBinaryPath || realBinaryPath === fs.realpathSync(__filename)) {
208
+ attempted.push(binaryPath);
209
+ process.stderr.write(
210
+ `[houdini-react] Could not locate the houdini-react binary for ${process.platform}-${process.arch}. Tried:\n` +
211
+ attempted.map((p) => ` - ${p}\n`).join('') +
212
+ `Install the platform package for your system or point HOUDINI_REACT_BINARY_PATH at the binary.\n`
213
+ );
214
+ process.exit(1);
167
215
  }
168
216
 
169
217
  try {
170
- execFileSync(getBinaryPath(), process.argv.slice(2), { stdio: 'inherit' });
218
+ execFileSync(binaryPath, process.argv.slice(2), { stdio: 'inherit' });
171
219
  } catch (error) {
172
220
  process.exit(error.status || 1);
173
221
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "houdini-react",
3
- "version": "2.1.0",
3
+ "version": "2.1.2",
4
4
  "description": "The React plugin for houdini",
5
5
  "keywords": [
6
6
  "typescript",
@@ -34,6 +34,7 @@
34
34
  "estraverse": "^5.3.0",
35
35
  "express": "^5.1.0",
36
36
  "graphql-yoga": "^5.21.1",
37
+ "mrmime": "^2.0.1",
37
38
  "react": "^19.2.7",
38
39
  "react-dom": "^19.2.7",
39
40
  "react-streaming": "^0.4.17",
@@ -81,13 +82,13 @@
81
82
  }
82
83
  },
83
84
  "optionalDependencies": {
84
- "houdini-react-darwin-x64": "2.1.0",
85
- "houdini-react-darwin-arm64": "2.1.0",
86
- "houdini-react-linux-x64": "2.1.0",
87
- "houdini-react-linux-arm64": "2.1.0",
88
- "houdini-react-win32-x64": "2.1.0",
89
- "houdini-react-win32-arm64": "2.1.0",
90
- "houdini-react-wasm": "2.1.0"
85
+ "houdini-react-darwin-x64": "2.1.2",
86
+ "houdini-react-darwin-arm64": "2.1.2",
87
+ "houdini-react-linux-x64": "2.1.2",
88
+ "houdini-react-linux-arm64": "2.1.2",
89
+ "houdini-react-win32-x64": "2.1.2",
90
+ "houdini-react-win32-arm64": "2.1.2",
91
+ "houdini-react-wasm": "2.1.2"
91
92
  },
92
93
  "scripts": {
93
94
  "compile": "scripts build-go",
package/postInstall.js CHANGED
@@ -5,7 +5,7 @@ const https = require('https')
5
5
  const child_process = require('child_process')
6
6
 
7
7
  // Adjust the version you want to install. You can also make this dynamic.
8
- const BINARY_DISTRIBUTION_VERSION = '2.1.0'
8
+ const BINARY_DISTRIBUTION_VERSION = '2.1.2'
9
9
 
10
10
  // Windows binaries end with .exe so we need to special case them.
11
11
  const binaryName = process.platform === 'win32' ? 'houdini-react.exe' : 'houdini-react'
package/runtime/Link.tsx CHANGED
@@ -11,7 +11,7 @@ import type { RouteHrefs, ExternalHref, ParamsForRoute, SearchForRoute } from '.
11
11
  import { buildHref, type RouteHrefInfo } from './resolve-href.js'
12
12
 
13
13
  // re-exported for custom link wrappers that constrain their own `to` prop
14
- export type { RouteHrefs }
14
+ export type { RouteHrefs, ExternalHref }
15
15
 
16
16
  export type LinkProps<H extends RouteHrefs | ExternalHref = RouteHrefs | ExternalHref> = Omit<
17
17
  DetailedHTMLProps<AnchorHTMLAttributes<HTMLAnchorElement>, HTMLAnchorElement>,
@@ -4,8 +4,7 @@ import type { Cache } from 'houdini/runtime/cache'
4
4
  import type { DocumentStore, HoudiniClient } from 'houdini/runtime/client'
5
5
  import { getCurrentConfig } from '$houdini/runtime'
6
6
  import configFile from '$houdini/runtime/imports/config'
7
- import { deepEquals } from 'houdini/runtime'
8
- import type { LRUCache } from 'houdini/runtime'
7
+ import { deepEquals, LRUCache } from 'houdini/runtime'
9
8
  import { marshalSelection, marshalInputs, getAuthUrl, HOUDINI_SESSION_EVENT } from 'houdini/runtime'
10
9
  import type { HoudiniSessionEventDetail } from 'houdini/runtime'
11
10
  import { find_match, find_prefix_match } from 'houdini/router/match'
@@ -197,9 +196,9 @@ export function Router({
197
196
  injectToStream,
198
197
  })
199
198
  // if we get this far, it's safe to load the component
200
- const { component_cache, data_cache, artifact_cache, ssr_signals } = useRouterContext()
199
+ const { component_cache, data_cache, artifact_cache, ssr_signals, latestSession } =
200
+ useRouterContext()
201
201
  const PageComponent = component_cache.get(targetPage.id)!
202
- const [session] = useSession()
203
202
 
204
203
  // decide whether the entry should render its @loading frame instead of the page.
205
204
  // values come from the router config (bundled client-side; they're UI timing, not
@@ -354,7 +353,7 @@ export function Router({
354
353
  lastRevalidatedURL.current = currentURL
355
354
  for (const name of Object.keys(targetPage.documents)) {
356
355
  if (data_cache.has(name)) {
357
- data_cache.get(name).send({ variables, session })
356
+ data_cache.get(name).send({ variables, session: latestSession.current })
358
357
  }
359
358
  }
360
359
  }, [currentURL])
@@ -544,11 +543,9 @@ function usePageData({
544
543
  artifact_cache,
545
544
  ssr_signals,
546
545
  last_variables,
546
+ latestSession,
547
547
  } = useRouterContext()
548
548
 
549
- // grab the current session value
550
- const [session] = useSession()
551
-
552
549
  // the function to load a query using the cache references
553
550
  function load_query({
554
551
  id,
@@ -559,11 +556,12 @@ function usePageData({
559
556
  artifact: QueryArtifact
560
557
  variables: GraphQLVariables
561
558
  }): Promise<void> {
562
- // TODO: better tracking - only register the variables that were used
563
- // track the new variables
564
- for (const artifact of Object.keys(page.documents)) {
565
- last_variables.set(artifact, variables)
566
- }
559
+ // record the variables this document is being sent with. only the loaded document:
560
+ // a preload loads the *destination's* queries while another page is still rendered,
561
+ // so writing the whole page's documents here (the old behavior) would tag the
562
+ // current page's documents with the destination's variables — and never tag the
563
+ // preloaded document at all
564
+ last_variables.set(id, variables)
567
565
 
568
566
  // TODO: AbortController on send()
569
567
  // TODO: we can read from cache here before making an asynchronous network call
@@ -655,7 +653,10 @@ function usePageData({
655
653
  observer
656
654
  .send({
657
655
  variables: variables,
658
- session,
656
+ // read the ref, not the render-scoped session: this send can run during a
657
+ // render whose committed session predates a just-written one, and it must
658
+ // not repopulate the invalidated caches with stale-session results
659
+ session: latestSession.current,
659
660
  })
660
661
  .then(async () => {
661
662
  // a stale-generation result: release the signal so nothing hangs, but
@@ -947,6 +948,15 @@ export function RouterContextProvider({
947
948
  // on the server, we can just use
948
949
  const [session, setSession] = React.useState<App.Session>(ssrSession)
949
950
 
951
+ // the React state above lags behind session writes: a setSession scheduled inside a
952
+ // transition hasn't committed when another lane renders (e.g. the urgent isPending
953
+ // flip of a navigation started in the same transition), so a render-phase load_query
954
+ // in that window would send with the previous session and repopulate the caches the
955
+ // session change just invalidated — the new-session render then finds everything
956
+ // "cached" and never refetches. Every session write updates this ref synchronously,
957
+ // and query sends read it instead of the render-scoped state.
958
+ const latestSession = React.useRef<App.Session>(ssrSession)
959
+
950
960
  // if we detect an event that contains a new session value. The detail carries the subtree
951
961
  // and whether to merge it into the current session (an @session(merge:) upsert) or replace
952
962
  // it wholesale; a legacy plain-session detail is treated as a replace.
@@ -966,7 +976,8 @@ export function RouterContextProvider({
966
976
  // old session
967
977
  invalidate_session_caches({ data_cache, ssr_signals, last_variables })
968
978
 
969
- setSession((prev) => (merge ? { ...prev, ...next } : next))
979
+ latestSession.current = merge ? { ...latestSession.current, ...next } : next
980
+ setSession(latestSession.current)
970
981
  },
971
982
  [data_cache, ssr_signals, last_variables]
972
983
  )
@@ -991,9 +1002,19 @@ export function RouterContextProvider({
991
1002
  ssr_signals,
992
1003
  last_variables,
993
1004
  session,
994
- setSession: (newSession) => setSession((old) => ({ ...old, ...newSession })),
995
- replaceSession: (next) => setSession(next),
996
- clearSession: () => setSession({}),
1005
+ latestSession,
1006
+ setSession: (newSession) => {
1007
+ latestSession.current = { ...latestSession.current, ...newSession }
1008
+ setSession(latestSession.current)
1009
+ },
1010
+ replaceSession: (next) => {
1011
+ latestSession.current = next
1012
+ setSession(next)
1013
+ },
1014
+ clearSession: () => {
1015
+ latestSession.current = {}
1016
+ setSession({})
1017
+ },
997
1018
  formResult,
998
1019
  formToken,
999
1020
  }}
@@ -1027,6 +1048,12 @@ export type RouterContext = {
1027
1048
  // The current session
1028
1049
  session: App.Session
1029
1050
 
1051
+ // the most recent session value, written synchronously by every session setter. The
1052
+ // `session` state above only updates when React commits, which can lag the write by a
1053
+ // render (e.g. a setSession inside a transition) — anything that fires a request during
1054
+ // render (load_query) must read this instead so it can't send a stale session.
1055
+ latestSession: { current: App.Session }
1056
+
1030
1057
  // a function to call that sets the client-side session singletone
1031
1058
  setSession: (newSession: Partial<App.Session>) => void
1032
1059
 
@@ -1406,7 +1433,10 @@ export function router_cache({
1406
1433
  store.cleanup().catch(() => {})
1407
1434
  }),
1408
1435
  ssr_signals: suspense_cache(),
1409
- last_variables: suspense_cache(),
1436
+ // a plain LRU, NOT a suspense cache: readers look up documents that may have no
1437
+ // entry yet (useQueryResult reads it during render and in its commit effect, where
1438
+ // a suspense cache's thrown promise would land in the nearest error boundary)
1439
+ last_variables: new LRUCache(),
1410
1440
  }
1411
1441
 
1412
1442
  // we need to fill each query with an externally resolvable promise
package/vite/index.js CHANGED
@@ -6,7 +6,8 @@ import {
6
6
  client_build_directory
7
7
  } from "houdini/router/conventions";
8
8
  import { load_manifest } from "houdini/router/manifest";
9
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
9
+ import { lookup } from "mrmime";
10
+ import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
10
11
  import { createRequire } from "node:module";
11
12
  import { build } from "vite";
12
13
  import { transform_file } from "./transform.js";
@@ -287,7 +288,7 @@ mount_static_app(App, manifest)
287
288
  return;
288
289
  }
289
290
  const url = req.url.split("?")[0];
290
- if (url.startsWith("/@") || url.startsWith("/virtual:") || url.startsWith("/node_modules/") || /\.[a-z]+$/i.test(url)) {
291
+ if (url.startsWith("/@") || url.startsWith("/virtual:") || url.startsWith("/node_modules/") || /\.[a-z]+$/i.test(url) || lookup(url) !== void 0 || is_public_file(url, server.config.publicDir)) {
291
292
  next();
292
293
  return;
293
294
  }
@@ -304,18 +305,16 @@ mount_static_app(App, manifest)
304
305
  adapter_config_path(ctx.config)
305
306
  );
306
307
  const requestHeaders = new Headers();
307
- for (const header of Object.entries(req.headers ?? {})) {
308
- requestHeaders.set(header[0], header[1]);
308
+ for (const [name, value] of Object.entries(req.headers ?? {})) {
309
+ requestHeaders.set(name, Array.isArray(value) ? value.join(", ") : value ?? "");
309
310
  }
310
311
  const port = server.config.server.port ?? 5173;
311
- const request = new Request(
312
- `http://localhost:${port}` + req.url,
313
- req.method === "POST" ? {
314
- method: req.method,
315
- headers: requestHeaders,
316
- body: await getBody(req)
317
- } : void 0
318
- );
312
+ const hasBody = req.method !== "GET" && req.method !== "HEAD";
313
+ const request = new Request(`http://localhost:${port}` + req.url, {
314
+ method: req.method,
315
+ headers: requestHeaders,
316
+ ...hasBody ? { body: await getBody(req) } : {}
317
+ });
319
318
  let documentPremable = `<script type="module" src="/@vite/client" async=""></script>`;
320
319
  try {
321
320
  const transformed = await server.transformIndexHtml(
@@ -353,20 +352,17 @@ mount_static_app(App, manifest)
353
352
  if (res.closed) {
354
353
  return;
355
354
  }
356
- for (const header of result.headers ?? []) {
357
- res.setHeader(header[0], header[1]);
355
+ for (const [key, value] of result.headers ?? []) {
356
+ if (key.toLowerCase() !== "set-cookie") {
357
+ res.setHeader(key, value);
358
+ }
359
+ }
360
+ const setCookies = result.headers?.getSetCookie() ?? [];
361
+ if (setCookies.length > 0) {
362
+ res.setHeader("Set-Cookie", setCookies);
358
363
  }
359
364
  if (result.status >= 300 && result.status < 400) {
360
- res.writeHead(result.status, {
361
- Location: result.headers.get("Location") ?? "",
362
- ...[...result.headers].reduce(
363
- (headers, [key, value]) => ({
364
- ...headers,
365
- [key]: value
366
- }),
367
- {}
368
- )
369
- });
365
+ res.writeHead(result.status);
370
366
  } else {
371
367
  res.write(await result.text());
372
368
  }
@@ -380,6 +376,20 @@ mount_static_app(App, manifest)
380
376
  }
381
377
  };
382
378
  }
379
+ function is_public_file(url, publicDir) {
380
+ if (!publicDir) {
381
+ return false;
382
+ }
383
+ try {
384
+ const resolved = path.resolve(publicDir, `.${decodeURIComponent(url)}`);
385
+ if (!resolved.startsWith(publicDir + path.sep)) {
386
+ return false;
387
+ }
388
+ return statSync(resolved).isFile();
389
+ } catch {
390
+ return false;
391
+ }
392
+ }
383
393
  function getBody(request) {
384
394
  return new Promise((resolve) => {
385
395
  const bodyParts = [];